From c6291a285acaebabd7a8a92f38fed25bb90a51f7 Mon Sep 17 00:00:00 2001 From: Dotty Dotter Date: Wed, 1 Apr 2026 14:58:10 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20Globale=20Filter=20(Ratsperiode=20+=20P?= =?UTF-8?q?arteien)=20seiten=C3=BCbergreifend=20(#15)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Layout: - Sticky Filter-Bar unter Navigation auf ALLEN Seiten - Ratsperioden als Multi-Select Toggle-Buttons - Parteien-Buttons mit Parteifarben aus DB - Reset-Button bei aktiven Filtern Backend: - Shared utility tracker/core/perioden.py (Perioden-Mapping + Filter-Helper) - GET /api/vorlagen: periode + parteien Parameter - GET /api/ketten: periode + parteien Parameter - GET /api/stats/dashboard: periode + parteien Parameter - GET /api/fraktionen/{kuerzel}/dashboard: periode Parameter Frontend: - Shared reactive state (filters.svelte.ts) mit Svelte 5 Runes - $effect() in allen Seiten reagiert auf Filteränderungen - Dashboard, Vorlagen, Ketten, Abstimmungen, Fraktionen nutzen globale Filter - Filter-Bar aus Abstimmungen entfernt (jetzt im Layout) Closes #15 --- .../src/tracker/api/routes/abstimmungen.py | 22 +--- backend/src/tracker/api/routes/fraktionen.py | 9 ++ backend/src/tracker/api/routes/ketten.py | 20 ++++ backend/src/tracker/api/routes/stats.py | 100 ++++++++++++++---- backend/src/tracker/api/routes/vorlagen.py | 20 ++++ backend/src/tracker/core/perioden.py | 46 ++++++++ frontend/src/lib/api.ts | 9 +- frontend/src/lib/filters.svelte.ts | 84 +++++++++++++++ frontend/src/routes/+layout.svelte | 72 +++++++++++++ frontend/src/routes/+page.svelte | 18 +++- frontend/src/routes/abstimmungen/+page.svelte | 95 ++--------------- .../routes/fraktionen/[kuerzel]/+page.svelte | 7 +- frontend/src/routes/ketten/+page.svelte | 11 +- frontend/src/routes/vorlagen/+page.svelte | 11 +- 14 files changed, 385 insertions(+), 139 deletions(-) create mode 100644 backend/src/tracker/core/perioden.py create mode 100644 frontend/src/lib/filters.svelte.ts diff --git a/backend/src/tracker/api/routes/abstimmungen.py b/backend/src/tracker/api/routes/abstimmungen.py index 541a24a..ef6ae80 100644 --- a/backend/src/tracker/api/routes/abstimmungen.py +++ b/backend/src/tracker/api/routes/abstimmungen.py @@ -38,26 +38,8 @@ 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 + from tracker.core.perioden import periode_date_filter + return periode_date_filter(periode, "a.sitzung_datum") def _parteien_filter(parteien: str | None) -> tuple[str, list]: diff --git a/backend/src/tracker/api/routes/fraktionen.py b/backend/src/tracker/api/routes/fraktionen.py index a5b379c..1b96332 100644 --- a/backend/src/tracker/api/routes/fraktionen.py +++ b/backend/src/tracker/api/routes/fraktionen.py @@ -53,9 +53,12 @@ def list_fraktionen(conn=Depends(_db)): def fraktion_dashboard( kuerzel: str, jahr: Optional[int] = None, + periode: Optional[str] = None, conn=Depends(_db), ): """Dashboard for a single Fraktion with Umsetzungsanalyse.""" + from tracker.core.perioden import periode_date_filter + # Find party partei = conn.execute( "SELECT id, kuerzel, name, farbe FROM parteien WHERE kuerzel = ?", (kuerzel,) @@ -70,6 +73,12 @@ def fraktion_dashboard( jahr_filter = "AND strftime('%Y', v.datum_eingang) = ?" params.append(str(jahr)) + # Global filter: Ratsperiode + per_clause, per_params = periode_date_filter(periode, "v.datum_eingang") + if per_clause: + jahr_filter += f" AND {per_clause}" + params.extend(per_params) + # Total Anträge total = conn.execute(f""" SELECT COUNT(DISTINCT v.id) as c diff --git a/backend/src/tracker/api/routes/ketten.py b/backend/src/tracker/api/routes/ketten.py index d7899ff..a41f0f3 100644 --- a/backend/src/tracker/api/routes/ketten.py +++ b/backend/src/tracker/api/routes/ketten.py @@ -33,9 +33,13 @@ def list_ketten( typ: str | None = None, suche: str | None = None, partei: str | None = None, + periode: str | None = None, + parteien: str | None = None, conn=Depends(_db), ): """List Ketten with optional filters.""" + from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter + where_clauses = [] params: list = [] @@ -58,6 +62,22 @@ def list_ketten( where_clauses.append("k.thema LIKE ?") params.append(f"%{suche}%") + # Global filter: Ratsperiode (filter on letzte_aktivitaet) + per_clause, per_params = periode_date_filter(periode, "k.letzte_aktivitaet") + if per_clause: + where_clauses.append(per_clause) + params.extend(per_params) + + # Global filter: Parteien (multi-select on Ursprung-Antragsteller) + p_kuerzel = parteien_kuerzel_filter(parteien) + if p_kuerzel: + placeholders = ",".join("?" * len(p_kuerzel)) + where_clauses.append( + f"k.ursprung_id IN (SELECT a.vorlage_id FROM antragsteller a " + f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({placeholders}))" + ) + params.extend(p_kuerzel) + where_sql = ("WHERE " + " AND ".join(where_clauses)) if where_clauses else "" total = conn.execute( diff --git a/backend/src/tracker/api/routes/stats.py b/backend/src/tracker/api/routes/stats.py index 1e48273..a7655f3 100644 --- a/backend/src/tracker/api/routes/stats.py +++ b/backend/src/tracker/api/routes/stats.py @@ -1,8 +1,9 @@ from __future__ import annotations """API routes for Dashboard statistics.""" -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, Query +from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter from tracker.db.session import get_connection router = APIRouter(prefix="/stats", tags=["Statistics"]) @@ -112,30 +113,89 @@ def get_stats(conn=Depends(_db)): @router.get("/dashboard") -def get_dashboard_stats(conn=Depends(_db)): - """Dashboard-level aggregated stats for the new hub.""" - vorlagen_total = conn.execute("SELECT COUNT(*) as c FROM vorlagen").fetchone()["c"] - ketten_total = conn.execute("SELECT COUNT(*) as c FROM ketten").fetchone()["c"] - abstimmungen_total = conn.execute("SELECT COUNT(*) as c FROM abstimmungen").fetchone()["c"] +def get_dashboard_stats( + periode: str | None = None, + parteien: str | None = None, + conn=Depends(_db), +): + """Dashboard-level aggregated stats for the new hub, with optional global filters.""" + # Build WHERE clauses for vorlagen + v_where_parts: list[str] = [] + v_params: list = [] + per_clause, per_params = periode_date_filter(periode, "v.datum_eingang") + if per_clause: + v_where_parts.append(per_clause) + v_params.extend(per_params) + p_kuerzel = parteien_kuerzel_filter(parteien) + if p_kuerzel: + ph = ",".join("?" * len(p_kuerzel)) + v_where_parts.append( + f"v.id IN (SELECT a.vorlage_id FROM antragsteller a " + f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({ph}))" + ) + v_params.extend(p_kuerzel) + v_where = ("WHERE " + " AND ".join(v_where_parts)) if v_where_parts else "" - vorlagen_nach_typ = conn.execute(""" - SELECT typ, COUNT(*) as c FROM vorlagen - WHERE typ IS NOT NULL + # Build WHERE clauses for ketten + k_where_parts: list[str] = [] + k_params: list = [] + k_per_clause, k_per_params = periode_date_filter(periode, "k.letzte_aktivitaet") + if k_per_clause: + k_where_parts.append(k_per_clause) + k_params.extend(k_per_params) + if p_kuerzel: + ph = ",".join("?" * len(p_kuerzel)) + k_where_parts.append( + f"k.ursprung_id IN (SELECT a.vorlage_id FROM antragsteller a " + f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({ph}))" + ) + k_params.extend(p_kuerzel) + k_where = ("WHERE " + " AND ".join(k_where_parts)) if k_where_parts else "" + + # Build WHERE for abstimmungen + a_where_parts: list[str] = [] + a_params: list = [] + a_per_clause, a_per_params = periode_date_filter(periode, "ab.sitzung_datum") + if a_per_clause: + a_where_parts.append(a_per_clause) + a_params.extend(a_per_params) + a_where = ("WHERE " + " AND ".join(a_where_parts)) if a_where_parts else "" + + vorlagen_total = conn.execute( + f"SELECT COUNT(*) as c FROM vorlagen v {v_where}", v_params + ).fetchone()["c"] + ketten_total = conn.execute( + f"SELECT COUNT(*) as c FROM ketten k {k_where}", k_params + ).fetchone()["c"] + abstimmungen_total = conn.execute( + f"SELECT COUNT(*) as c FROM abstimmungen ab {a_where}", a_params + ).fetchone()["c"] + + vorlagen_nach_typ = conn.execute(f""" + SELECT typ, COUNT(*) as c FROM vorlagen v + {v_where + (' AND' if v_where else 'WHERE')} typ IS NOT NULL GROUP BY typ ORDER BY c DESC - """).fetchall() + """.replace("WHERE AND", "WHERE"), v_params).fetchall() - ketten_nach_status = conn.execute(""" - SELECT status, COUNT(*) as c FROM ketten - WHERE status IS NOT NULL + ketten_nach_status = conn.execute(f""" + SELECT status, COUNT(*) as c FROM ketten k + {k_where + (' AND' if k_where else 'WHERE')} status IS NOT NULL GROUP BY status ORDER BY c DESC - """).fetchall() + """.replace("WHERE AND", "WHERE"), k_params).fetchall() - # Umsetzungsquote: umgesetzt, teilweise_umgesetzt, versandet aus ketten - umgesetzt = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'umgesetzt'").fetchone()["c"] - teilweise = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'teilweise_umgesetzt'").fetchone()["c"] - versandet = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'versandet'").fetchone()["c"] - beschlossen = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'beschlossen'").fetchone()["c"] - abgelehnt = conn.execute("SELECT COUNT(*) as c FROM ketten WHERE status = 'abgelehnt'").fetchone()["c"] + # Umsetzungsquote + def _k_count(status_val: str) -> int: + extra = f" AND k.status = ?" if k_where else "WHERE k.status = ?" + return conn.execute( + f"SELECT COUNT(*) as c FROM ketten k {k_where}{extra}", + k_params + [status_val], + ).fetchone()["c"] + + umgesetzt = _k_count("umgesetzt") + teilweise = _k_count("teilweise_umgesetzt") + versandet = _k_count("versandet") + beschlossen = _k_count("beschlossen") + abgelehnt = _k_count("abgelehnt") total_bewertet = umgesetzt + teilweise + versandet + beschlossen + abgelehnt return { diff --git a/backend/src/tracker/api/routes/vorlagen.py b/backend/src/tracker/api/routes/vorlagen.py index 6a5feaf..d5fee85 100644 --- a/backend/src/tracker/api/routes/vorlagen.py +++ b/backend/src/tracker/api/routes/vorlagen.py @@ -95,9 +95,13 @@ def list_vorlagen( typ: str | None = None, suche: str | None = None, partei: str | None = None, + periode: str | None = None, + parteien: str | None = None, conn=Depends(_db), ): """List Vorlagen with optional filters.""" + from tracker.core.perioden import periode_date_filter, parteien_kuerzel_filter + where_clauses = [] params: list = [] @@ -112,6 +116,22 @@ def list_vorlagen( ) params.append(partei) + # Global filter: Ratsperiode + per_clause, per_params = periode_date_filter(periode, "v.datum_eingang") + if per_clause: + where_clauses.append(per_clause) + params.extend(per_params) + + # Global filter: Parteien (multi-select) + p_kuerzel = parteien_kuerzel_filter(parteien) + if p_kuerzel: + placeholders = ",".join("?" * len(p_kuerzel)) + where_clauses.append( + f"v.id IN (SELECT a.vorlage_id FROM antragsteller a " + f"JOIN parteien p ON a.partei_id = p.id WHERE p.kuerzel IN ({placeholders}))" + ) + params.extend(p_kuerzel) + if suche: # Try FTS5 first, fall back to LIKE has_fts = conn.execute( diff --git a/backend/src/tracker/core/perioden.py b/backend/src/tracker/core/perioden.py new file mode 100644 index 0000000..a11b51e --- /dev/null +++ b/backend/src/tracker/core/perioden.py @@ -0,0 +1,46 @@ +"""Shared Perioden filter utilities for all routers.""" +from __future__ import annotations + +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"), +} + + +def parse_perioden(periode: str | None) -> list[str]: + """Parse comma-separated perioden string into valid period keys.""" + if not periode: + return [] + return [p.strip() for p in periode.split(",") if p.strip() in PERIODEN] + + +def periode_date_filter(periode: str | None, date_column: str) -> tuple[str, list]: + """Return (WHERE clause fragment, params) for filtering a date column by Ratsperiode. + + Args: + periode: Comma-separated perioden string (e.g. "2020-2025,2025-2030") + date_column: SQL column expression to filter on (e.g. "v.datum_eingang", "a.sitzung_datum") + + Returns: + Tuple of (SQL fragment, list of params). Empty string if no filter. + """ + selected = parse_perioden(periode) + if not selected: + return "", [] + conditions = [] + params = [] + for p in selected: + start, end = PERIODEN[p] + conditions.append(f"({date_column} >= ? AND {date_column} <= ?)") + params.extend([start, end]) + return f"({' OR '.join(conditions)})", params + + +def parteien_kuerzel_filter(parteien: str | None) -> list[str]: + """Parse comma-separated parteien string into kuerzel list.""" + if not parteien: + return [] + return [p.strip() for p in parteien.split(",") if p.strip()] diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index b604544..ccedd5c 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -216,7 +216,10 @@ export const fetchSuchvorschlaege = (q: string) => get<{ items: SuchVorschlag[] }>(`/vorlagen/suggest?q=${encodeURIComponent(q)}`); export const fetchFraktionen = () => get<{ id: number; kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>('/fraktionen'); -export const fetchFraktionDashboard = (kuerzel: string, jahr?: string) => { - const params = jahr ? `?jahr=${jahr}` : ''; - return get(`/fraktionen/${kuerzel}/dashboard${params}`); +export const fetchFraktionDashboard = (kuerzel: string, jahr?: string, periode?: string) => { + const p = new URLSearchParams(); + if (jahr) p.set('jahr', jahr); + if (periode) p.set('periode', periode); + const qs = p.toString(); + return get(`/fraktionen/${kuerzel}/dashboard${qs ? `?${qs}` : ''}`); }; diff --git a/frontend/src/lib/filters.svelte.ts b/frontend/src/lib/filters.svelte.ts new file mode 100644 index 0000000..033537e --- /dev/null +++ b/frontend/src/lib/filters.svelte.ts @@ -0,0 +1,84 @@ +/** + * Shared global filter state for Ratsperiode + Parteien. + * Uses Svelte 5 Runes ($state). + */ + +export const PERIODEN = ['2025-2030', '2020-2025', '2014-2020', '2009-2014', '2004-2009']; + +export interface Fraktion { + kuerzel: string; + name: string; + farbe: string | null; + anzahl: number; +} + +// Reactive filter state — module-level $state +export const filters = $state({ + perioden: [] as string[], + parteien: [] as string[], +}); + +// Available fraktionen (loaded once from API) +export const fraktionenList = $state<{ items: Fraktion[] }>({ items: [] }); + +export function togglePeriode(p: string) { + if (filters.perioden.includes(p)) { + filters.perioden = filters.perioden.filter((x) => x !== p); + } else { + filters.perioden = [...filters.perioden, p]; + } +} + +export function togglePartei(p: string) { + if (filters.parteien.includes(p)) { + filters.parteien = filters.parteien.filter((x) => x !== p); + } else { + filters.parteien = [...filters.parteien, p]; + } +} + +export function clearFilters() { + filters.perioden = []; + filters.parteien = []; +} + +export function hasActiveFilters(): boolean { + return filters.perioden.length > 0 || filters.parteien.length > 0; +} + +/** + * Build URLSearchParams from active filters. + * Append to any existing params for API calls. + */ +export function filterParams(): URLSearchParams { + const params = new URLSearchParams(); + if (filters.perioden.length > 0) { + params.set('periode', filters.perioden.join(',')); + } + if (filters.parteien.length > 0) { + params.set('parteien', filters.parteien.join(',')); + } + return params; +} + +/** + * Merge filter params into an existing Record. + */ +export function mergeFilterParams(existing: Record): Record { + const merged = { ...existing }; + if (filters.perioden.length > 0) { + merged.periode = filters.perioden.join(','); + } + if (filters.parteien.length > 0) { + merged.parteien = filters.parteien.join(','); + } + return merged; +} + +/** + * Returns a reactive "version" that changes when filters change. + * Use this in $effect() to trigger reloads. + */ +export function filterVersion(): string { + return `${filters.perioden.join(',')}_${filters.parteien.join(',')}`; +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 00fe31b..c5d44f1 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -1,7 +1,29 @@
@@ -59,6 +81,56 @@ {/if} + +
+
+
+ +
+ Periode +
+ {#each PERIODEN as p} + + {/each} +
+
+ + + + + +
+ Partei +
+ {#each fraktionenList.items as f} + + {/each} +
+
+ + + {#if hasActiveFilters()} + + {/if} +
+
+
+
{@render children()} diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 3888336..f69d593 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,6 +1,7 @@ diff --git a/frontend/src/routes/abstimmungen/+page.svelte b/frontend/src/routes/abstimmungen/+page.svelte index 9b66442..3548cb9 100644 --- a/frontend/src/routes/abstimmungen/+page.svelte +++ b/frontend/src/routes/abstimmungen/+page.svelte @@ -1,6 +1,6 @@ diff --git a/frontend/src/routes/vorlagen/+page.svelte b/frontend/src/routes/vorlagen/+page.svelte index 0a0f019..ad8f1fa 100644 --- a/frontend/src/routes/vorlagen/+page.svelte +++ b/frontend/src/routes/vorlagen/+page.svelte @@ -3,6 +3,7 @@ import { goto } from '$app/navigation'; import { fetchVorlagen, fetchFraktionen, fetchSuchvorschlaege, type VorlageKurz, type Paginated, type SuchVorschlag } from '$lib/api'; import { formatDate } from '$lib/status'; + import { filters, mergeFilterParams, filterVersion } from '$lib/filters.svelte'; let data: Paginated | null = $state(null); let parteien = $state<{ kuerzel: string; name: string; farbe: string | null; anzahl: number }[]>([]); @@ -88,10 +89,11 @@ async function load() { loading = true; try { - const params: Record = { page: String(currentPage), page_size: '50' }; + let params: Record = { page: String(currentPage), page_size: '50' }; if (filterTyp) params.typ = filterTyp; if (filterSuche) params.suche = filterSuche; if (filterPartei) params.partei = filterPartei; + params = mergeFilterParams(params); data = await fetchVorlagen(params); } catch (e) { error = e instanceof Error ? e.message : 'Fehler'; @@ -124,6 +126,13 @@ syncFromUrl(); load(); }); + + // Reload when global filters change + $effect(() => { + filterVersion(); + currentPage = 1; + load(); + });