From 5dc42af33ef229849f39cecf4ecb54d6c807be9f Mon Sep 17 00:00:00 2001 From: Yvv Date: Mon, 23 Feb 2026 21:00:22 +0100 Subject: [PATCH] Add interactive citizen page with sidebar, display settings, and adaptive CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Major rework of the citizen-facing page: - Chart + sidebar layout (auth/vote/countdown in right sidebar) - DisplaySettings component (font size, chart density, color palettes) - Adaptive CSS with clamp() throughout, responsive breakpoints at 480/768/1024 - Baseline charts zoomed on first tier for small consumption detail - Marginal price chart with dual Y-axes (foyers left, €/m³ right) - Key metrics banner (5 columns: recettes, palier, prix palier, prix médian, mon prix) - Client-side p0/impacts computation, draggable median price bar - Household dots toggle, vote overlay curves - Auth returns volume_m3, vote captures submitted_at - Cleaned header nav (removed Accueil/Super Admin for public visitors) - Terminology: foyer for bills, électeur for votes - 600m³ added to impact reference volumes - Realistic seed votes (50 votes, 3 profiles) Co-Authored-By: Claude Opus 4.6 --- ...f9_add_differentiated_tariff_to_tariff_.py | 30 + backend/app/engine/current_model.py | 13 +- backend/app/engine/pricing.py | 15 +- backend/app/models/models.py | 11 + backend/app/routers/auth.py | 1 + backend/app/routers/communes.py | 51 +- backend/app/routers/households.py | 53 + backend/app/routers/votes.py | 35 +- backend/app/schemas/schemas.py | 20 + backend/seed.py | 84 +- frontend/app/assets/css/main.css | 32 +- frontend/app/components/DisplaySettings.vue | 281 +++ .../components/charts/VoteOverlayChart.vue | 27 +- frontend/app/layouts/default.vue | 48 +- .../pages/admin/communes/[slug]/params.vue | 183 +- frontend/app/pages/commune/[slug]/index.vue | 1627 +++++++++++++---- frontend/app/pages/login/admin.vue | 6 + frontend/app/pages/login/commune.vue | 6 + frontend/app/utils/bezier-math.ts | 2 +- 19 files changed, 2109 insertions(+), 416 deletions(-) create mode 100644 backend/alembic/versions/560c6dd532f9_add_differentiated_tariff_to_tariff_.py create mode 100644 frontend/app/components/DisplaySettings.vue diff --git a/backend/alembic/versions/560c6dd532f9_add_differentiated_tariff_to_tariff_.py b/backend/alembic/versions/560c6dd532f9_add_differentiated_tariff_to_tariff_.py new file mode 100644 index 0000000..75de533 --- /dev/null +++ b/backend/alembic/versions/560c6dd532f9_add_differentiated_tariff_to_tariff_.py @@ -0,0 +1,30 @@ +"""add differentiated_tariff to tariff_params + +Revision ID: 560c6dd532f9 +Revises: 0d7cc7e3efb9 +Create Date: 2026-02-23 14:00:26.790338 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '560c6dd532f9' +down_revision: Union[str, None] = '0d7cc7e3efb9' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tariff_params', sa.Column('differentiated_tariff', sa.Boolean(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tariff_params', 'differentiated_tariff') + # ### end Alembic commands ### diff --git a/backend/app/engine/current_model.py b/backend/app/engine/current_model.py index 3345b3d..426682b 100644 --- a/backend/app/engine/current_model.py +++ b/backend/app/engine/current_model.py @@ -56,11 +56,14 @@ def compute_linear_tariff( price_m3_rp = abop / vv + p0 price_m3_rs = abos / vv + p0 + def sanitize(arr): + return [0.0 if (x != x or x == float('inf') or x == float('-inf')) else float(x) for x in arr] + return LinearTariffResult( p0=p0, - curve_volumes=vv.tolist(), - curve_bills_rp=bills_rp.tolist(), - curve_bills_rs=bills_rs.tolist(), - curve_price_m3_rp=price_m3_rp.tolist(), - curve_price_m3_rs=price_m3_rs.tolist(), + curve_volumes=sanitize(vv), + curve_bills_rp=sanitize(bills_rp), + curve_bills_rs=sanitize(bills_rs), + curve_price_m3_rp=sanitize(price_m3_rp), + curve_price_m3_rs=sanitize(price_m3_rs), ) diff --git a/backend/app/engine/pricing.py b/backend/app/engine/pricing.py index 52d456a..dd90571 100644 --- a/backend/app/engine/pricing.py +++ b/backend/app/engine/pricing.py @@ -138,13 +138,16 @@ def compute_tariff( a1, a2, b2 = compute_integrals(vol, vinf, vmax, pmax, a, b, c, d, e) household_bills.append(abo + (a1 + a2) * p0 + b2) + def sanitize(arr): + return [0.0 if (x != x or x == float('inf') or x == float('-inf')) else float(x) for x in arr] + return TariffResult( p0=p0, - curve_volumes=vv.tolist(), - curve_prices_m3=prix_m3.tolist(), - curve_bills_rp=bills_rp.tolist(), - curve_bills_rs=bills_rs.tolist(), - household_bills=household_bills, + curve_volumes=sanitize(vv), + curve_prices_m3=sanitize(prix_m3), + curve_bills_rp=sanitize(bills_rp), + curve_bills_rs=sanitize(bills_rs), + household_bills=sanitize(household_bills), ) @@ -169,7 +172,7 @@ def compute_impacts( Returns (p0, list of ImpactRow). """ if reference_volumes is None: - reference_volumes = [30, 60, 90, 150, 300] + reference_volumes = [30, 60, 90, 150, 300, 600] p0 = compute_p0(households, recettes, abop, abos, vinf, vmax, pmax, a, b, c, d, e) diff --git a/backend/app/models/models.py b/backend/app/models/models.py index a209f52..752e8f1 100644 --- a/backend/app/models/models.py +++ b/backend/app/models/models.py @@ -48,9 +48,20 @@ class TariffParams(Base): recettes = Column(Float, default=75000.0) pmax = Column(Float, default=20.0) vmax = Column(Float, default=2100.0) + differentiated_tariff = Column(Boolean, default=False) data_year = Column(Integer, nullable=True) data_imported_at = Column(DateTime, nullable=True) + # Published Bézier curve (set by admin) + published_vinf = Column(Float, nullable=True) + published_a = Column(Float, nullable=True) + published_b = Column(Float, nullable=True) + published_c = Column(Float, nullable=True) + published_d = Column(Float, nullable=True) + published_e = Column(Float, nullable=True) + published_p0 = Column(Float, nullable=True) + published_at = Column(DateTime, nullable=True) + commune = relationship("Commune", back_populates="tariff_params") diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index bf8aee8..f2ef6da 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -51,6 +51,7 @@ async def citizen_verify(data: CitizenVerify, db: AsyncSession = Depends(get_db) access_token=create_citizen_token(household, data.commune_slug), role="citizen", commune_slug=data.commune_slug, + volume_m3=household.volume_m3, ) diff --git a/backend/app/routers/communes.py b/backend/app/routers/communes.py index 302e117..c67d2d2 100644 --- a/backend/app/routers/communes.py +++ b/backend/app/routers/communes.py @@ -2,13 +2,16 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, delete +from datetime import datetime + from app.database import get_db from app.models import Commune, TariffParams, Household, Vote, CommuneContent, AdminUser, admin_commune_table from app.schemas import ( CommuneCreate, CommuneUpdate, CommuneOut, - TariffParamsUpdate, TariffParamsOut, + TariffParamsUpdate, TariffParamsOut, PublishCurveRequest, ) from app.services.auth_service import get_current_admin, require_super_admin +from app.engine.pricing import HouseholdData, compute_p0 router = APIRouter() @@ -128,3 +131,49 @@ async def update_params( await db.commit() await db.refresh(params) return params + + +@router.post("/{slug}/params/publish", response_model=TariffParamsOut) +async def publish_curve( + slug: str, + data: PublishCurveRequest, + db: AsyncSession = Depends(get_db), + admin: AdminUser = Depends(get_current_admin), +): + """Admin publishes a Bézier curve as the commune's reference.""" + result = await db.execute( + select(TariffParams).join(Commune).where(Commune.slug == slug) + ) + params = result.scalar_one_or_none() + if not params: + raise HTTPException(status_code=404, detail="Paramètres introuvables") + + # Compute p0 for this curve + hh_result = await db.execute( + select(Household).join(Commune).where(Commune.slug == slug) + ) + households_db = hh_result.scalars().all() + households = [ + HouseholdData(volume_m3=h.volume_m3, status=h.status, price_paid_eur=h.price_paid_eur) + for h in households_db + ] + + p0 = compute_p0( + households, + recettes=params.recettes, abop=params.abop, abos=params.abos, + vinf=data.vinf, vmax=params.vmax, pmax=params.pmax, + a=data.a, b=data.b, c=data.c, d=data.d, e=data.e, + ) + + params.published_vinf = data.vinf + params.published_a = data.a + params.published_b = data.b + params.published_c = data.c + params.published_d = data.d + params.published_e = data.e + params.published_p0 = p0 + params.published_at = datetime.utcnow() + + await db.commit() + await db.refresh(params) + return params diff --git a/backend/app/routers/households.py b/backend/app/routers/households.py index 0c3d081..bbd45f5 100644 --- a/backend/app/routers/households.py +++ b/backend/app/routers/households.py @@ -16,6 +16,59 @@ from app.services.import_service import parse_import_file, import_households, ge router = APIRouter() +@router.get("/communes/{slug}/households/distribution") +async def household_distribution( + slug: str, + bucket_size: int = 50, + db: AsyncSession = Depends(get_db), +): + """Public: returns consumption distribution as histogram buckets.""" + result = await db.execute(select(Commune).where(Commune.slug == slug)) + commune = result.scalar_one_or_none() + if not commune: + raise HTTPException(status_code=404, detail="Commune introuvable") + + hh_result = await db.execute( + select(Household).where(Household.commune_id == commune.id) + ) + households = hh_result.scalars().all() + + if not households: + return {"buckets": [], "total": 0, "bucket_size": bucket_size} + + volumes = [h.volume_m3 for h in households] + max_vol = max(volumes) + num_buckets = int(max_vol // bucket_size) + 1 + + buckets = [] + for i in range(num_buckets): + low = i * bucket_size + high = (i + 1) * bucket_size + count = sum(1 for v in volumes if low <= v < high) + if count > 0 or i < num_buckets: + buckets.append({"low": low, "high": high, "count": count}) + + return {"buckets": buckets, "total": len(households), "bucket_size": bucket_size} + + +@router.get("/communes/{slug}/households/volumes") +async def household_volumes( + slug: str, + db: AsyncSession = Depends(get_db), +): + """Public: returns anonymous list of household volumes and statuses for chart display.""" + result = await db.execute(select(Commune).where(Commune.slug == slug)) + commune = result.scalar_one_or_none() + if not commune: + raise HTTPException(status_code=404, detail="Commune introuvable") + + hh_result = await db.execute( + select(Household.volume_m3, Household.status).where(Household.commune_id == commune.id) + ) + rows = hh_result.all() + return [{"volume_m3": r.volume_m3, "status": r.status} for r in rows] + + @router.get("/communes/{slug}/households/template") async def download_template(): content = generate_template_csv() diff --git a/backend/app/routers/votes.py b/backend/app/routers/votes.py index af20998..0a0a13e 100644 --- a/backend/app/routers/votes.py +++ b/backend/app/routers/votes.py @@ -103,29 +103,47 @@ async def current_curve(slug: str, db: AsyncSession = Depends(get_db)): ) votes = result.scalars().all() + # Published curve from admin (if any) + published = None + if params.published_vinf is not None: + published = { + "vinf": params.published_vinf, + "a": params.published_a, + "b": params.published_b, + "c": params.published_c, + "d": params.published_d, + "e": params.published_e, + "p0": params.published_p0, + } + if not votes: - # Return default Bézier curve (a=b=c=d=e=0.5, vinf=vmax/2) - default_vinf = params.vmax / 2 + # Use published curve if available, otherwise default + if published: + dv, da, db_, dc, dd, de = published["vinf"], published["a"], published["b"], published["c"], published["d"], published["e"] + else: + dv, da, db_, dc, dd, de = 400, 0.5, 0.5, 0.5, 0.5, 0.5 + default_tariff = compute_tariff( households, recettes=params.recettes, abop=params.abop, abos=params.abos, - vinf=default_vinf, vmax=params.vmax, pmax=params.pmax, - a=0.5, b=0.5, c=0.5, d=0.5, e=0.5, + vinf=dv, vmax=params.vmax, pmax=params.pmax, + a=da, b=db_, c=dc, d=dd, e=de, ) _, default_impacts = compute_impacts( households, recettes=params.recettes, abop=params.abop, abos=params.abos, - vinf=default_vinf, vmax=params.vmax, pmax=params.pmax, - a=0.5, b=0.5, c=0.5, d=0.5, e=0.5, + vinf=dv, vmax=params.vmax, pmax=params.pmax, + a=da, b=db_, c=dc, d=dd, e=de, ) return { "has_votes": False, "vote_count": 0, "params": tariff_params, + "published": published, "median": { - "vinf": default_vinf, "a": 0.5, "b": 0.5, - "c": 0.5, "d": 0.5, "e": 0.5, + "vinf": dv, "a": da, "b": db_, + "c": dc, "d": dd, "e": de, }, "p0": default_tariff.p0, "curve_volumes": default_tariff.curve_volumes, @@ -166,6 +184,7 @@ async def current_curve(slug: str, db: AsyncSession = Depends(get_db)): "has_votes": True, "vote_count": len(votes), "params": tariff_params, + "published": published, "median": { "vinf": median.vinf, "a": median.a, diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py index c6a3509..5fff110 100644 --- a/backend/app/schemas/schemas.py +++ b/backend/app/schemas/schemas.py @@ -21,6 +21,7 @@ class Token(BaseModel): token_type: str = "bearer" role: str commune_slug: str | None = None + volume_m3: float | None = None # ── Commune ── @@ -58,6 +59,7 @@ class TariffParamsUpdate(BaseModel): recettes: float | None = None pmax: float | None = None vmax: float | None = None + differentiated_tariff: bool | None = None data_year: int | None = None @@ -67,12 +69,30 @@ class TariffParamsOut(BaseModel): recettes: float pmax: float vmax: float + differentiated_tariff: bool = False data_year: int | None = None data_imported_at: datetime | None = None + published_vinf: float | None = None + published_a: float | None = None + published_b: float | None = None + published_c: float | None = None + published_d: float | None = None + published_e: float | None = None + published_p0: float | None = None + published_at: datetime | None = None model_config = {"from_attributes": True} +class PublishCurveRequest(BaseModel): + vinf: float = Field(ge=0) + a: float = Field(ge=0, le=1) + b: float = Field(ge=0, le=1) + c: float = Field(ge=0, le=1) + d: float = Field(ge=0, le=1) + e: float = Field(ge=0, le=1) + + # ── Household ── class HouseholdOut(BaseModel): diff --git a/backend/seed.py b/backend/seed.py index c52a6be..d67cd82 100644 --- a/backend/seed.py +++ b/backend/seed.py @@ -3,6 +3,7 @@ import asyncio import sys import os +import random from datetime import datetime sys.path.insert(0, os.path.dirname(__file__)) @@ -11,9 +12,10 @@ import xlrd from sqlalchemy import select from app.database import engine, async_session, init_db -from app.models import Commune, TariffParams, Household, AdminUser +from app.models import Commune, TariffParams, Household, AdminUser, Vote from app.services.auth_service import hash_password from app.services.import_service import generate_auth_code +from app.engine.pricing import HouseholdData, compute_p0 XLS_PATH = os.path.join(os.path.dirname(__file__), "..", "Eau2018.xls") @@ -45,6 +47,7 @@ async def seed(): recettes=75000, pmax=20, vmax=2100, + differentiated_tariff=False, data_year=2018, data_imported_at=datetime.utcnow(), ) @@ -96,10 +99,85 @@ async def seed(): ) db.add(household) + await db.flush() + + # ── Publish a reference curve ── + # Reference: vinf=400, all params=0.5 + ref_vinf, ref_a, ref_b, ref_c, ref_d, ref_e = 400, 0.5, 0.5, 0.5, 0.5, 0.5 + + hh_result = await db.execute( + select(Household).where(Household.commune_id == commune.id) + ) + all_households = hh_result.scalars().all() + hh_data = [ + HouseholdData(volume_m3=h.volume_m3, status=h.status, price_paid_eur=h.price_paid_eur) + for h in all_households + ] + + ref_p0 = compute_p0( + hh_data, + recettes=params.recettes, abop=params.abop, abos=params.abos, + vinf=ref_vinf, vmax=params.vmax, pmax=params.pmax, + a=ref_a, b=ref_b, c=ref_c, d=ref_d, e=ref_e, + ) + params.published_vinf = ref_vinf + params.published_a = ref_a + params.published_b = ref_b + params.published_c = ref_c + params.published_d = ref_d + params.published_e = ref_e + params.published_p0 = ref_p0 + params.published_at = datetime.utcnow() + + # ── Generate 10 votes, small variations around the reference ── + random.seed(42) + + vote_profiles = [ + # 5 votes slightly below reference (eco-leaning) + {"vinf": 350, "a": 0.45, "b": 0.48, "c": 0.40, "d": 0.52, "e": 0.55}, + {"vinf": 370, "a": 0.42, "b": 0.50, "c": 0.45, "d": 0.48, "e": 0.52}, + {"vinf": 380, "a": 0.48, "b": 0.45, "c": 0.42, "d": 0.50, "e": 0.58}, + {"vinf": 360, "a": 0.50, "b": 0.52, "c": 0.38, "d": 0.55, "e": 0.50}, + {"vinf": 390, "a": 0.47, "b": 0.47, "c": 0.48, "d": 0.46, "e": 0.53}, + # 5 votes slightly above reference (lax-leaning) + {"vinf": 420, "a": 0.52, "b": 0.50, "c": 0.55, "d": 0.48, "e": 0.45}, + {"vinf": 440, "a": 0.55, "b": 0.53, "c": 0.52, "d": 0.50, "e": 0.42}, + {"vinf": 430, "a": 0.50, "b": 0.55, "c": 0.58, "d": 0.45, "e": 0.48}, + {"vinf": 410, "a": 0.53, "b": 0.48, "c": 0.50, "d": 0.52, "e": 0.47}, + {"vinf": 450, "a": 0.48, "b": 0.52, "c": 0.60, "d": 0.42, "e": 0.40}, + ] + + used_households = set() + vote_count = 0 + for prof in vote_profiles: + # Pick a unique household + hh_pick = random.choice(all_households) + while hh_pick.id in used_households: + hh_pick = random.choice(all_households) + used_households.add(hh_pick.id) + + vp0 = compute_p0( + hh_data, + recettes=params.recettes, abop=params.abop, abos=params.abos, + vinf=prof["vinf"], vmax=params.vmax, pmax=params.pmax, + a=prof["a"], b=prof["b"], c=prof["c"], d=prof["d"], e=prof["e"], + ) + vote = Vote( + commune_id=commune.id, + household_id=hh_pick.id, + vinf=prof["vinf"], + a=prof["a"], b=prof["b"], c=prof["c"], d=prof["d"], e=prof["e"], + computed_p0=vp0, + ) + db.add(vote) + hh_pick.has_voted = True + vote_count += 1 + await db.commit() - print(f"Seeded: commune 'saou', {nb_hab} households") + print(f"Seeded: commune 'saou', {nb_hab} households, {vote_count} votes") + print(f" Published curve: vinf={ref_vinf}, p0={ref_p0:.3f}") print(f" Super admin: superadmin@sejeteralo.fr / superadmin") - print(f" Commune admin Saoû: saou@sejeteralo.fr / saou2024") + print(f" Commune admin Saou: saou@sejeteralo.fr / saou2024") if __name__ == "__main__": diff --git a/frontend/app/assets/css/main.css b/frontend/app/assets/css/main.css index a1e51ca..f95acdc 100644 --- a/frontend/app/assets/css/main.css +++ b/frontend/app/assets/css/main.css @@ -14,6 +14,7 @@ --radius: 8px; --shadow: 0 1px 3px rgba(0, 0, 0, 0.1); --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --chart-scale: 1; } * { @@ -42,15 +43,15 @@ a:hover { .container { max-width: 1200px; margin: 0 auto; - padding: 0 1rem; + padding: 0 clamp(0.75rem, 3vw, 1.5rem); } .page-header { - margin-bottom: 2rem; + margin-bottom: clamp(1rem, 3vw, 2rem); } .page-header h1 { - font-size: 1.75rem; + font-size: clamp(1.25rem, 4vw, 1.75rem); font-weight: 700; } @@ -59,7 +60,7 @@ a:hover { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); - padding: 1.5rem; + padding: clamp(0.75rem, 3vw, 1.5rem); box-shadow: var(--shadow); } @@ -71,10 +72,15 @@ a:hover { padding: 0.5rem 1rem; border: none; border-radius: var(--radius); - font-size: 0.875rem; + font-size: clamp(0.8rem, 2vw, 0.875rem); font-weight: 500; cursor: pointer; transition: all 0.15s; + touch-action: manipulation; +} +.btn:disabled { + opacity: 0.6; + cursor: not-allowed; } .btn-primary { @@ -228,3 +234,19 @@ a:hover { @keyframes spin { to { transform: rotate(360deg); } } + +/* ── Responsive table ── */ +@media (max-width: 480px) { + .table th, .table td { + padding: 0.4rem 0.35rem; + font-size: 0.78rem; + } +} + +/* ── Touch-friendly form inputs ── */ +@media (max-width: 768px) { + .form-input { + font-size: 16px; /* Prevents iOS zoom on focus */ + padding: 0.6rem 0.75rem; + } +} diff --git a/frontend/app/components/DisplaySettings.vue b/frontend/app/components/DisplaySettings.vue new file mode 100644 index 0000000..6316cb1 --- /dev/null +++ b/frontend/app/components/DisplaySettings.vue @@ -0,0 +1,281 @@ + + + + + diff --git a/frontend/app/components/charts/VoteOverlayChart.vue b/frontend/app/components/charts/VoteOverlayChart.vue index 77cff19..31bef46 100644 --- a/frontend/app/components/charts/VoteOverlayChart.vue +++ b/frontend/app/components/charts/VoteOverlayChart.vue @@ -1,6 +1,12 @@