feat: Globale Multi-Select Filter (Ratsperiode + Parteien) auf Abstimmungen

- 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
This commit is contained in:
Dotty Dotter 2026-04-01 14:46:57 +02:00
parent ea3e5cd329
commit 7358aa1b61
2 changed files with 218 additions and 25 deletions

View File

@ -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") @router.get("/fraktionen")
def get_fraktionen_uebersicht(conn=Depends(_db)): def get_fraktionen_uebersicht(
"""Stimmverhalten aller Fraktionen aggregiert.""" periode: str | None = None,
rows = conn.execute(""" parteien: str | None = None,
SELECT fraktion, conn=Depends(_db),
SUM(CASE WHEN stimme='ja' THEN 1 ELSE 0 END) as ja, ):
SUM(CASE WHEN stimme='nein' THEN 1 ELSE 0 END) as nein, """Stimmverhalten aller Fraktionen aggregiert, optional nach Ratsperiode/Parteien gefiltert."""
SUM(CASE WHEN stimme='enthaltung' THEN 1 ELSE 0 END) as enthaltung, 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 COUNT(*) as gesamt
FROM abstimmungen_fraktionen FROM abstimmungen_fraktionen af
GROUP BY fraktion {join_sql}
{where_sql}
GROUP BY af.fraktion
ORDER BY gesamt DESC ORDER BY gesamt DESC
""").fetchall() """, params).fetchall()
# Normalize and aggregate # Normalize and aggregate
from collections import defaultdict from collections import defaultdict
@ -75,14 +139,29 @@ def get_fraktionen_uebersicht(conn=Depends(_db)):
@router.get("/koalitionsmatrix") @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?""" """Matrix: Wie oft stimmen Fraktionen gleich ab?"""
# Alle Abstimmungen mit mindestens 2 Fraktionen where_parts = ["af.stimme IN ('ja', 'nein')"]
abstimmungen = conn.execute(""" params: list = []
SELECT abstimmung_id, fraktion, stimme
FROM abstimmungen_fraktionen per_clause, per_params = _periode_filter(periode)
WHERE stimme IN ('ja', 'nein') if per_clause:
""").fetchall() 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) # Gruppieren nach Abstimmung (normalisiert)
from collections import defaultdict from collections import defaultdict
@ -104,6 +183,11 @@ def get_koalitionsmatrix(conn=Depends(_db)):
if stimmen[f1] == stimmen[f2]: if stimmen[f1] == stimmen[f2]:
matrix[f1][f2]["gleich"] += 1 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 # Als Liste für Frontend
result = [] result = []
for f1 in sorted(fraktionen): for f1 in sorted(fraktionen):

View File

@ -9,6 +9,7 @@
enthaltung: number; enthaltung: number;
gesamt: number; gesamt: number;
ja_quote: number; ja_quote: number;
ist_ratsfraktion?: boolean;
} }
interface Uebereinstimmung { interface Uebereinstimmung {
@ -52,6 +53,12 @@
let loading = $state(true); let loading = $state(true);
let error = $state(''); let error = $state('');
// Global filters
const PERIODEN = ['2025-2030', '2020-2025', '2014-2020', '2009-2014', '2004-2009'];
let selectedPerioden = $state<string[]>([]);
let selectedParteien = $state<string[]>([]);
let allParteien = $state<string[]>([]);
// Filter state for detail view // Filter state for detail view
let selectedFraktion = $state(''); let selectedFraktion = $state('');
let selectedStimme = $state(''); let selectedStimme = $state('');
@ -74,20 +81,67 @@
: '/api') : '/api')
: '/api'; : '/api';
onMount(async () => { async function loadAll() {
loading = true;
try { 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([ const [frakRes, koalRes] = await Promise.all([
fetch(`${API_BASE}/abstimmungen/fraktionen`), fetch(`${API_BASE}/abstimmungen/fraktionen${suffix}`),
fetch(`${API_BASE}/abstimmungen/koalitionsmatrix`) 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(); if (koalRes.ok) koalitionsmatrix = await koalRes.json();
} catch (e) { } catch (e) {
error = `Fehler: ${e}`; error = `Fehler: ${e}`;
} finally { } finally {
loading = false; 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) { async function loadDetails(fraktion: string, stimme: string, page: number = 1) {
@ -188,6 +242,53 @@
<p class="text-gray-500 text-sm mt-1">Analyse des Stimmverhaltens der Ratsfraktionen</p> <p class="text-gray-500 text-sm mt-1">Analyse des Stimmverhaltens der Ratsfraktionen</p>
</div> </div>
<!-- Global Filters -->
<div class="bg-white rounded-xl shadow-sm border border-gray-200 p-4 mb-6">
<div class="flex flex-col sm:flex-row gap-4">
<!-- Ratsperioden -->
<div class="flex-1">
<span class="text-xs font-medium text-gray-500 uppercase mb-2 block">Ratsperiode</span>
<div class="flex flex-wrap gap-1.5">
{#each PERIODEN as p}
<button onclick={() => togglePeriode(p)}
class="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all
{selectedPerioden.includes(p)
? 'bg-green-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
{p}
</button>
{/each}
</div>
</div>
<!-- Parteien -->
<div class="flex-1">
<span class="text-xs font-medium text-gray-500 uppercase mb-2 block">Parteien</span>
<div class="flex flex-wrap gap-1.5">
{#each allParteien as p}
<button onclick={() => togglePartei(p)}
class="px-2.5 py-1.5 rounded-lg text-xs font-medium transition-all
{selectedParteien.includes(p)
? 'bg-green-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}">
{p}
</button>
{/each}
</div>
</div>
<!-- Reset -->
{#if selectedPerioden.length > 0 || selectedParteien.length > 0}
<div class="flex items-end">
<button onclick={clearFilters}
class="text-xs text-gray-500 hover:text-gray-700 border border-gray-300 rounded-lg px-3 py-1.5 hover:bg-gray-50 whitespace-nowrap">
✕ Filter zurücksetzen
</button>
</div>
{/if}
</div>
</div>
{#if error} {#if error}
<div class="bg-red-50 text-red-700 p-4 rounded-lg mb-6">{error}</div> <div class="bg-red-50 text-red-700 p-4 rounded-lg mb-6">{error}</div>
{/if} {/if}
@ -245,8 +346,12 @@
<td class="px-4 py-3 text-center text-gray-600">{f.gesamt}</td> <td class="px-4 py-3 text-center text-gray-600">{f.gesamt}</td>
<td class="px-4 py-3"> <td class="px-4 py-3">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<div class="flex-1 bg-gray-200 rounded-full h-2 max-w-24"> <div class="flex-1 flex rounded-full h-3 max-w-32 overflow-hidden bg-gray-200">
<div class="bg-green-500 h-2 rounded-full" style="width: {f.ja_quote}%"></div> {#if f.gesamt > 0}
<div class="bg-green-500 h-full" style="width: {f.ja / f.gesamt * 100}%" title="Ja: {f.ja}"></div>
<div class="bg-red-500 h-full" style="width: {f.nein / f.gesamt * 100}%" title="Nein: {f.nein}"></div>
<div class="bg-yellow-400 h-full" style="width: {f.enthaltung / f.gesamt * 100}%" title="Enthaltung: {f.enthaltung}"></div>
{/if}
</div> </div>
<span class="text-sm text-gray-600">{f.ja_quote}%</span> <span class="text-sm text-gray-600">{f.ja_quote}%</span>
</div> </div>
@ -268,8 +373,12 @@
</button> </button>
<span class="text-sm font-medium text-gray-600">{f.ja_quote}%</span> <span class="text-sm font-medium text-gray-600">{f.ja_quote}%</span>
</div> </div>
<div class="bg-gray-200 rounded-full h-2.5 mb-3"> <div class="flex rounded-full h-2.5 mb-3 overflow-hidden bg-gray-200">
<div class="bg-green-500 h-2.5 rounded-full" style="width: {f.ja_quote}%"></div> {#if f.gesamt > 0}
<div class="bg-green-500 h-full" style="width: {f.ja / f.gesamt * 100}%"></div>
<div class="bg-red-500 h-full" style="width: {f.nein / f.gesamt * 100}%"></div>
<div class="bg-yellow-400 h-full" style="width: {f.enthaltung / f.gesamt * 100}%"></div>
{/if}
</div> </div>
<div class="grid grid-cols-4 gap-2 text-center text-xs"> <div class="grid grid-cols-4 gap-2 text-center text-xs">
<button onclick={() => loadDetails(f.fraktion, 'ja')} class="cursor-pointer hover:bg-green-50 rounded p-1 transition-colors"> <button onclick={() => loadDetails(f.fraktion, 'ja')} class="cursor-pointer hover:bg-green-50 rounded p-1 transition-colors">