From 7358aa1b61350e5f6d2e0ad642b2b82586121373 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 1 Apr 2026 14:46:57 +0200 Subject: [PATCH] feat: Globale Multi-Select Filter (Ratsperiode + Parteien) auf Abstimmungen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Filter-Bar oben: Ratsperioden (2004-2030) + Parteien als Toggle-Buttons - Multi-Select: Mehrere Perioden/Parteien gleichzeitig wählbar - Filter wirken auf Stimmverhalten-Tabelle UND Koalitionsmatrix - Backend: periode= und parteien= Query-Parameter auf /fraktionen + /koalitionsmatrix - Fraktions-Normalisierung: Mapping für 40+ DB-Varianten - Gestapelte Balken (grün/rot/gelb) statt nur grün - Filter bleiben beim Scrollen aktiv, Reset-Button wenn Filter gesetzt --- .../src/tracker/api/routes/abstimmungen.py | 118 ++++++++++++++--- frontend/src/routes/abstimmungen/+page.svelte | 125 ++++++++++++++++-- 2 files changed, 218 insertions(+), 25 deletions(-) diff --git a/backend/src/tracker/api/routes/abstimmungen.py b/backend/src/tracker/api/routes/abstimmungen.py index 4c4cafd..541a24a 100644 --- a/backend/src/tracker/api/routes/abstimmungen.py +++ b/backend/src/tracker/api/routes/abstimmungen.py @@ -36,19 +36,83 @@ def get_abstimmungen_stats(conn=Depends(_db)): } +def _periode_filter(periode: str | None) -> tuple[str, list]: + """Return (WHERE clause fragment, params) for Ratsperiode filtering on abstimmungen.sitzung_datum.""" + PERIODEN = { + "2025-2030": ("2025-11-01", "2030-10-31"), + "2020-2025": ("2020-11-01", "2025-10-31"), + "2014-2020": ("2014-06-01", "2020-10-31"), + "2009-2014": ("2009-09-01", "2014-05-31"), + "2004-2009": ("2004-09-01", "2009-08-31"), + } + if not periode: + return "", [] + # Support multi-select: "2020-2025,2025-2030" + selected = [p.strip() for p in periode.split(",") if p.strip() in PERIODEN] + if not selected: + return "", [] + conditions = [] + params = [] + for p in selected: + start, end = PERIODEN[p] + conditions.append("(a.sitzung_datum >= ? AND a.sitzung_datum <= ?)") + params.extend([start, end]) + return f"({' OR '.join(conditions)})", params + + +def _parteien_filter(parteien: str | None) -> tuple[str, list]: + """Return (WHERE clause fragment, params) for multi-party filter on abstimmungen_fraktionen.""" + if not parteien: + return "", [] + selected = [p.strip() for p in parteien.split(",") if p.strip()] + # Expand each to all DB variants + all_variants = [] + for p in selected: + all_variants.extend(get_all_variants(p)) + if not all_variants: + return "", [] + placeholders = ",".join("?" * len(all_variants)) + return f"af_filter.fraktion IN ({placeholders})", all_variants + + @router.get("/fraktionen") -def get_fraktionen_uebersicht(conn=Depends(_db)): - """Stimmverhalten aller Fraktionen aggregiert.""" - rows = conn.execute(""" - SELECT fraktion, - SUM(CASE WHEN stimme='ja' THEN 1 ELSE 0 END) as ja, - SUM(CASE WHEN stimme='nein' THEN 1 ELSE 0 END) as nein, - SUM(CASE WHEN stimme='enthaltung' THEN 1 ELSE 0 END) as enthaltung, +def get_fraktionen_uebersicht( + periode: str | None = None, + parteien: str | None = None, + conn=Depends(_db), +): + """Stimmverhalten aller Fraktionen aggregiert, optional nach Ratsperiode/Parteien gefiltert.""" + where_parts = [] + params: list = [] + + per_clause, per_params = _periode_filter(periode) + if per_clause: + where_parts.append(per_clause) + params.extend(per_params) + + # If parteien filter is set, only show those parties + par_clause, par_params = _parteien_filter(parteien) + if par_clause: + # Filter on the main fraktion column + par_clause_main = par_clause.replace("af_filter.fraktion", "af.fraktion") + where_parts.append(par_clause_main) + params.extend(par_params) + + join_sql = "JOIN abstimmungen a ON af.abstimmung_id = a.id" if per_clause else "" + where_sql = ("WHERE " + " AND ".join(where_parts)) if where_parts else "" + + rows = conn.execute(f""" + SELECT af.fraktion, + SUM(CASE WHEN af.stimme='ja' THEN 1 ELSE 0 END) as ja, + SUM(CASE WHEN af.stimme='nein' THEN 1 ELSE 0 END) as nein, + SUM(CASE WHEN af.stimme='enthaltung' THEN 1 ELSE 0 END) as enthaltung, COUNT(*) as gesamt - FROM abstimmungen_fraktionen - GROUP BY fraktion + FROM abstimmungen_fraktionen af + {join_sql} + {where_sql} + GROUP BY af.fraktion ORDER BY gesamt DESC - """).fetchall() + """, params).fetchall() # Normalize and aggregate from collections import defaultdict @@ -75,14 +139,29 @@ def get_fraktionen_uebersicht(conn=Depends(_db)): @router.get("/koalitionsmatrix") -def get_koalitionsmatrix(conn=Depends(_db)): +def get_koalitionsmatrix( + periode: str | None = None, + parteien: str | None = None, + conn=Depends(_db), +): """Matrix: Wie oft stimmen Fraktionen gleich ab?""" - # Alle Abstimmungen mit mindestens 2 Fraktionen - abstimmungen = conn.execute(""" - SELECT abstimmung_id, fraktion, stimme - FROM abstimmungen_fraktionen - WHERE stimme IN ('ja', 'nein') - """).fetchall() + where_parts = ["af.stimme IN ('ja', 'nein')"] + params: list = [] + + per_clause, per_params = _periode_filter(periode) + if per_clause: + where_parts.append(per_clause) + params.extend(per_params) + + join_sql = "JOIN abstimmungen a ON af.abstimmung_id = a.id" if per_clause else "" + where_sql = "WHERE " + " AND ".join(where_parts) + + abstimmungen = conn.execute(f""" + SELECT af.abstimmung_id, af.fraktion, af.stimme + FROM abstimmungen_fraktionen af + {join_sql} + {where_sql} + """, params).fetchall() # Gruppieren nach Abstimmung (normalisiert) from collections import defaultdict @@ -104,6 +183,11 @@ def get_koalitionsmatrix(conn=Depends(_db)): if stimmen[f1] == stimmen[f2]: matrix[f1][f2]["gleich"] += 1 + # Filter factions if parteien specified + if parteien: + selected = set(p.strip() for p in parteien.split(",") if p.strip()) + fraktionen = [f for f in fraktionen if f in selected] + # Als Liste für Frontend result = [] for f1 in sorted(fraktionen): diff --git a/frontend/src/routes/abstimmungen/+page.svelte b/frontend/src/routes/abstimmungen/+page.svelte index b4ab36a..9b66442 100644 --- a/frontend/src/routes/abstimmungen/+page.svelte +++ b/frontend/src/routes/abstimmungen/+page.svelte @@ -9,6 +9,7 @@ enthaltung: number; gesamt: number; ja_quote: number; + ist_ratsfraktion?: boolean; } interface Uebereinstimmung { @@ -52,6 +53,12 @@ let loading = $state(true); let error = $state(''); + // Global filters + const PERIODEN = ['2025-2030', '2020-2025', '2014-2020', '2009-2014', '2004-2009']; + let selectedPerioden = $state([]); + let selectedParteien = $state([]); + let allParteien = $state([]); + // Filter state for detail view let selectedFraktion = $state(''); let selectedStimme = $state(''); @@ -74,20 +81,67 @@ : '/api') : '/api'; - onMount(async () => { + async function loadAll() { + loading = true; try { + const params = new URLSearchParams(); + if (selectedPerioden.length > 0) params.set('periode', selectedPerioden.join(',')); + if (selectedParteien.length > 0) params.set('parteien', selectedParteien.join(',')); + const qs = params.toString(); + const suffix = qs ? `?${qs}` : ''; + const [frakRes, koalRes] = await Promise.all([ - fetch(`${API_BASE}/abstimmungen/fraktionen`), - fetch(`${API_BASE}/abstimmungen/koalitionsmatrix`) + fetch(`${API_BASE}/abstimmungen/fraktionen${suffix}`), + fetch(`${API_BASE}/abstimmungen/koalitionsmatrix${suffix}`) ]); - if (frakRes.ok) fraktionen = await frakRes.json(); + if (frakRes.ok) { + fraktionen = await frakRes.json(); + // Build party list from unfiltered data for multi-select + if (allParteien.length === 0) { + allParteien = fraktionen.filter(f => f.ist_ratsfraktion).map(f => f.fraktion).sort(); + } + } if (koalRes.ok) koalitionsmatrix = await koalRes.json(); } catch (e) { error = `Fehler: ${e}`; } finally { loading = false; } + } + + function togglePeriode(p: string) { + if (selectedPerioden.includes(p)) { + selectedPerioden = selectedPerioden.filter(x => x !== p); + } else { + selectedPerioden = [...selectedPerioden, p]; + } + clearDetail(); + clearVergleich(); + loadAll(); + } + + function togglePartei(p: string) { + if (selectedParteien.includes(p)) { + selectedParteien = selectedParteien.filter(x => x !== p); + } else { + selectedParteien = [...selectedParteien, p]; + } + clearDetail(); + clearVergleich(); + loadAll(); + } + + function clearFilters() { + selectedPerioden = []; + selectedParteien = []; + clearDetail(); + clearVergleich(); + loadAll(); + } + + onMount(() => { + loadAll(); }); async function loadDetails(fraktion: string, stimme: string, page: number = 1) { @@ -188,6 +242,53 @@

Analyse des Stimmverhaltens der Ratsfraktionen

+ +
+
+ +
+ Ratsperiode +
+ {#each PERIODEN as p} + + {/each} +
+
+ + +
+ Parteien +
+ {#each allParteien as p} + + {/each} +
+
+ + + {#if selectedPerioden.length > 0 || selectedParteien.length > 0} +
+ +
+ {/if} +
+
+ {#if error}
{error}
{/if} @@ -245,8 +346,12 @@ {f.gesamt}
-
-
+
+ {#if f.gesamt > 0} +
+
+
+ {/if}
{f.ja_quote}%
@@ -268,8 +373,12 @@ {f.ja_quote}%
-
-
+
+ {#if f.gesamt > 0} +
+
+
+ {/if}