2026-03-28 22:30:24 +01:00
<!DOCTYPE html>
< html lang = "de" >
< head >
< meta charset = "UTF-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1.0" >
< title > {{ app_name }}< / title >
< style >
:root {
--color-darkgray: #5a5a5a;
--color-green: #889e33;
--color-blue: #009da5;
--color-lightgray: #bfbfbf;
--color-bg: #f5f5f5;
2026-04-10 23:56:29 +02:00
--color-card: white;
--color-text: #5a5a5a;
--color-border: #e0e0e0;
}
[data-theme="dark"] {
--color-darkgray: #d0d0d0;
--color-bg: #1a1a2e;
--color-card: #16213e;
--color-text: #d0d0d0;
--color-lightgray: #2a2a4a;
--color-border: #333366;
2026-03-28 22:30:24 +01:00
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Avenir', 'Segoe UI', sans-serif;
2026-04-10 23:56:29 +02:00
color: var(--color-text);
2026-03-28 22:30:24 +01:00
line-height: 1.6;
background: var(--color-bg);
2026-04-07 13:48:55 +02:00
min-height: 100vh;
min-height: 100dvh;
display: flex;
flex-direction: column;
2026-03-28 22:30:24 +01:00
}
/* Header */
.header {
2026-04-10 23:56:29 +02:00
background: var(--color-card);
2026-03-28 22:30:24 +01:00
padding: 1rem 2rem;
border-bottom: 1px solid var(--color-lightgray);
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.header h1 {
color: var(--color-blue);
font-size: 1.5rem;
}
.header .subtitle {
color: var(--color-darkgray);
font-size: 0.9rem;
}
.bundesland-select {
padding: 0.5rem 1rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-size: 0.9rem;
2026-04-10 23:56:29 +02:00
background: var(--color-card);
2026-03-28 22:30:24 +01:00
cursor: pointer;
}
.bundesland-select:focus {
outline: none;
border-color: var(--color-blue);
}
/* Main Layout */
.main-container {
display: flex;
2026-04-07 13:48:55 +02:00
flex: 1;
min-height: 0;
2026-03-28 22:30:24 +01:00
}
/* Left Panel - List */
.list-panel {
width: 400px;
min-width: 300px;
2026-04-10 23:56:29 +02:00
background: var(--color-card);
2026-03-28 22:30:24 +01:00
border-right: 1px solid var(--color-lightgray);
display: flex;
flex-direction: column;
}
.list-header {
padding: 1rem;
border-bottom: 1px solid var(--color-lightgray);
}
.search-row {
display: flex;
gap: 0.5rem;
}
.search-box {
flex: 1;
padding: 0.75rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-size: 0.9rem;
}
.btn-landtag {
padding: 0.5rem 0.75rem;
background: var(--color-green);
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
white-space: nowrap;
}
.btn-landtag:hover {
background: #728a2b;
}
.btn-landtag:disabled {
background: #ccc;
cursor: wait;
}
.list-filters {
display: flex;
gap: 0.5rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.filter-btn {
padding: 0.25rem 0.75rem;
border: 1px solid var(--color-lightgray);
border-radius: 20px;
2026-04-10 23:56:29 +02:00
background: var(--color-card);
2026-03-28 22:30:24 +01:00
cursor: pointer;
font-size: 0.8rem;
}
.filter-btn.active {
background: var(--color-blue);
color: white;
border-color: var(--color-blue);
}
.list-content {
flex: 1;
overflow-y: auto;
}
.list-item {
padding: 1rem;
border-bottom: 1px solid var(--color-lightgray);
cursor: pointer;
transition: background 0.2s;
}
.list-item:hover {
background: var(--color-bg);
}
.list-item.active {
background: #e8f4f5;
border-left: 3px solid var(--color-blue);
}
.list-item-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.25rem;
}
.list-item-id {
font-weight: bold;
color: var(--color-blue);
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
/* Bundesland-Badge: Im Listen-Item links neben der Drucksachen-Nummer.
Im Bundesland-spezifischen Modus per data-mode="single" am Container
ausgeblendet (redundant, da alle Einträge demselben Land zugehören). */
.bl-badge {
display: inline-block;
padding: 1px 6px;
margin-right: 0.4rem;
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--color-blue);
border: 1px solid var(--color-blue);
border-radius: 3px;
vertical-align: middle;
}
.list-content[data-mode="single"] .bl-badge { display: none; }
/* Detail-Header: Parlament-Name unter dem Titel, vor der Drucksache-Zeile */
.detail-parlament {
font-size: 0.85rem;
color: var(--color-blue);
font-weight: 600;
margin: 0.2rem 0 0.1rem;
}
2026-03-28 22:30:24 +01:00
.list-item-score {
font-size: 0.9rem;
font-weight: bold;
padding: 0.1rem 0.5rem;
border-radius: 4px;
}
/* Neue Skala -5 bis +5 */
.score-high { background: #155724; color: white; } /* +4 bis +5: dunkelgrün */
.score-mid { background: #889e33; color: white; } /* +2 bis +3: GWÖ-grün */
.score-neutral { background: #6c757d; color: white; } /* 0 bis +1: grau */
.score-low { background: #fd7e14; color: white; } /* -1 bis -2: orange */
.score-negative { background: #dc3545; color: white; } /* -3 bis -5: rot */
.score-none { background: #e2e3e5; color: #383d41; }
.status-unchecked {
background: #fff3cd;
color: #856404;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.status-checked {
background: #d4edda;
color: #155724;
font-size: 0.75rem;
padding: 0.2rem 0.5rem;
border-radius: 4px;
}
.btn-check-now {
display: inline-block;
padding: 0.3rem 0.75rem;
background: var(--color-blue);
color: white;
border: none;
border-radius: 4px;
font-size: 0.8rem;
cursor: pointer;
margin-top: 0.5rem;
}
.btn-check-now:hover {
background: #007b82;
}
.btn-check-now:disabled {
background: #ccc;
cursor: wait;
}
.list-item-title {
font-size: 0.9rem;
color: var(--color-darkgray);
margin-bottom: 0.25rem;
}
.list-item-meta {
font-size: 0.8rem;
color: #888;
}
.list-item-tags {
display: flex;
gap: 0.25rem;
margin-top: 0.5rem;
flex-wrap: wrap;
}
.tag {
font-size: 0.7rem;
padding: 0.1rem 0.4rem;
background: var(--color-bg);
border-radius: 3px;
}
/* Right Panel - Detail */
.detail-panel {
flex: 1;
overflow-y: auto;
padding: 2rem;
}
.detail-placeholder {
text-align: center;
color: #888;
padding: 4rem;
}
.detail-card {
2026-04-10 23:56:29 +02:00
background: var(--color-card);
2026-03-28 22:30:24 +01:00
border-radius: 8px;
padding: 2rem;
box-shadow: 0 2px 8px rgba(0,0,0,0.1);
margin-bottom: 1rem;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 1rem;
}
.detail-title {
font-size: 1.3rem;
color: var(--color-blue);
}
.detail-id {
font-size: 0.9rem;
color: #888;
}
.score-display {
text-align: center;
padding: 1rem;
}
.score-big {
font-size: 3rem;
font-weight: bold;
color: var(--color-blue);
}
.score-label {
font-size: 0.9rem;
color: #888;
}
.matrix-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 0.5rem;
margin: 1rem 0;
}
.matrix-item {
padding: 0.75rem;
background: var(--color-bg);
border-radius: 4px;
display: flex;
justify-content: space-between;
align-items: center;
}
.matrix-label {
font-size: 0.85rem;
}
.matrix-rating {
font-weight: bold;
}
.rating-pos { color: var(--color-green); }
.rating-neg { color: #dc3545; }
.rating-neutral { color: #888; }
/* Matrix Table (5x5 Grid wie im PDF) */
.matrix-table {
width: 100%;
border-collapse: collapse;
margin: 0.5rem 0;
font-size: 0.85rem;
}
.matrix-table th, .matrix-table td {
border: 1px solid var(--color-lightgray);
padding: 0.4rem 0.5rem;
text-align: center;
}
.matrix-table thead th {
background: var(--color-blue);
color: white;
font-weight: normal;
}
.matrix-table tbody th {
background: var(--color-bg);
text-align: left;
font-weight: normal;
}
.matrix-table .positive {
background: var(--color-green);
color: white;
font-weight: bold;
}
.matrix-table .negative {
background: #dc3545;
color: white;
font-weight: bold;
}
.matrix-table .neutral {
background: #f0f0f0;
}
/* Themen Tags in Detail */
.themen-tags .tag {
display: inline-block;
margin-right: 0.5rem;
margin-bottom: 0.25rem;
padding: 0.25rem 0.5rem;
background: var(--color-blue);
color: white;
border-radius: 3px;
font-size: 0.8rem;
}
/* Kernpunkte Liste */
.kernpunkte-list {
margin-left: 1.5rem;
}
.kernpunkte-list li {
margin-bottom: 0.5rem;
}
.section-title {
font-size: 1.1rem;
color: var(--color-darkgray);
margin: 1.5rem 0 0.75rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid var(--color-lightgray);
}
.text-block {
background: var(--color-bg);
padding: 1rem;
border-radius: 4px;
margin: 0.5rem 0;
}
.strength-list, .weakness-list {
list-style: none;
}
.strength-list li::before {
content: "✓ ";
color: var(--color-green);
}
.weakness-list li::before {
content: "✗ ";
color: #dc3545;
}
.btn-pdf {
display: inline-block;
padding: 0.75rem 1.5rem;
background: var(--color-blue);
color: white;
text-decoration: none;
border-radius: 4px;
margin-top: 1rem;
}
.btn-pdf:hover {
background: #007b82;
}
/* Upload Tab */
.upload-section {
2026-04-10 23:56:29 +02:00
background: var(--color-card);
2026-03-28 22:30:24 +01:00
border-radius: 8px;
padding: 2rem;
max-width: 600px;
margin: 0 auto;
}
.tabs {
display: flex;
gap: 1rem;
margin-bottom: 1.5rem;
}
.tab-btn {
padding: 0.5rem 1rem;
border: none;
background: none;
cursor: pointer;
border-bottom: 2px solid transparent;
color: var(--color-darkgray);
}
.tab-btn.active {
border-bottom-color: var(--color-blue);
color: var(--color-blue);
}
textarea {
width: 100%;
min-height: 200px;
padding: 1rem;
border: 1px solid var(--color-lightgray);
border-radius: 4px;
font-family: inherit;
resize: vertical;
}
.file-drop {
border: 2px dashed var(--color-lightgray);
border-radius: 8px;
padding: 3rem;
text-align: center;
cursor: pointer;
}
.file-drop:hover {
border-color: var(--color-blue);
}
.btn-analyze {
display: block;
width: 100%;
padding: 1rem;
background: var(--color-green);
color: white;
border: none;
border-radius: 4px;
font-size: 1rem;
cursor: pointer;
margin-top: 1rem;
}
.btn-analyze:hover {
background: #728a2b;
}
.btn-analyze:disabled {
background: #ccc;
cursor: not-allowed;
}
/* Mode Toggle */
.mode-toggle {
display: flex;
background: var(--color-bg);
border-radius: 4px;
padding: 0.25rem;
margin-left: auto;
}
.mode-btn {
padding: 0.5rem 1rem;
border: none;
background: none;
cursor: pointer;
border-radius: 4px;
2026-04-10 22:25:52 +02:00
font-size: 0.9rem;
2026-03-28 22:30:24 +01:00
color: var(--color-darkgray);
}
.mode-btn.active {
2026-04-10 23:56:29 +02:00
background: var(--color-card);
2026-03-28 22:30:24 +01:00
box-shadow: 0 1px 3px rgba(0,0,0,0.1);
}
2026-04-10 22:29:55 +02:00
.hamburger-dropdown {
display: none;
position: absolute;
right: 0;
top: 100%;
2026-04-10 23:56:29 +02:00
background: var(--color-card);
2026-04-10 22:29:55 +02:00
border: 1px solid #ddd;
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
min-width: 180px;
z-index: 100;
padding: 0.3rem 0;
}
.hamburger-dropdown.open { display: block; }
.hamburger-dropdown a,
.hamburger-dropdown button {
display: block;
width: 100%;
padding: 0.5rem 1rem;
text-align: left;
text-decoration: none;
color: var(--color-darkgray);
background: none;
border: none;
cursor: pointer;
font-size: 0.9rem;
}
.hamburger-dropdown a:hover,
.hamburger-dropdown button:hover {
background: #f5f5f5;
}
2026-03-28 22:30:24 +01:00
/* Loading */
.loading {
display: flex;
align-items: center;
gap: 1rem;
padding: 2rem;
}
.spinner {
width: 30px;
height: 30px;
border: 3px solid var(--color-lightgray);
border-top-color: var(--color-blue);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
2026-04-07 13:48:55 +02:00
/* Mobile Back-to-List Button (nur Mobile sichtbar) */
.btn-back-mobile {
display: none;
padding: 0.5rem 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-lightgray);
border-radius: 4px;
cursor: pointer;
margin-bottom: 1rem;
font-size: 0.85rem;
color: var(--color-darkgray);
}
2026-03-28 22:30:24 +01:00
/* Responsive */
@media (max-width: 900px) {
2026-04-07 13:48:55 +02:00
.header {
padding: 0.75rem 1rem;
gap: 0.5rem;
}
.header h1 {
font-size: 1.2rem;
}
.header .subtitle {
font-size: 0.8rem;
flex-basis: 100%;
order: 10;
}
.mode-toggle {
margin-left: auto;
flex-wrap: wrap;
}
.mode-btn {
padding: 0.4rem 0.6rem;
font-size: 0.85rem;
}
2026-03-28 22:30:24 +01:00
.main-container {
flex-direction: column;
2026-04-07 13:48:55 +02:00
flex: 1;
min-height: 0;
2026-03-28 22:30:24 +01:00
}
2026-04-07 13:48:55 +02:00
2026-03-28 22:30:24 +01:00
.list-panel {
width: 100%;
2026-04-07 13:48:55 +02:00
min-width: 0;
border-right: none;
border-bottom: 1px solid var(--color-lightgray);
}
.list-content {
2026-03-28 22:30:24 +01:00
max-height: 50vh;
}
2026-04-07 13:48:55 +02:00
2026-03-28 22:30:24 +01:00
.detail-panel {
padding: 1rem;
2026-04-07 13:48:55 +02:00
overflow: visible;
flex: none;
}
.btn-back-mobile {
display: inline-block;
2026-03-28 22:30:24 +01:00
}
}
2026-04-07 13:48:55 +02:00
@media (max-width: 600px) {
.header h1 { font-size: 1.1rem; }
.header .subtitle { display: none; }
.detail-card { padding: 1rem; }
.matrix-table { font-size: 0.7rem; }
.matrix-table th, .matrix-table td { padding: 0.25rem 0.3rem; }
.matrix-grid { grid-template-columns: 1fr; }
.upload-section { padding: 1rem; }
.file-drop { padding: 1.5rem; }
.score-big { font-size: 2.2rem; }
.detail-header { flex-wrap: wrap; gap: 0.5rem; }
}
2026-03-28 22:30:24 +01:00
/* Stats Bar */
.stats-bar {
display: flex;
gap: 2rem;
padding: 1rem;
background: var(--color-bg);
border-bottom: 1px solid var(--color-lightgray);
}
.stat {
text-align: center;
}
.stat-value {
font-size: 1.5rem;
font-weight: bold;
color: var(--color-blue);
}
.stat-label {
font-size: 0.8rem;
color: #888;
}
< / style >
< / head >
< body >
< header class = "header" >
< h1 > {{ app_name }}< / h1 >
< span class = "subtitle" > Gemeinwohl-Matrix 2.0 für Gemeinden< / span >
< select class = "bundesland-select" id = "bundesland-select" onchange = "changeBundesland(this.value)" >
{% for bl in bundeslaender %}
< option value = "{{ bl.code }}" { % if bl . active % } { % else % } disabled { % endif % } >
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
< / option >
{% endfor %}
< / select >
< div class = "mode-toggle" >
< button class = "mode-btn active" onclick = "showMode('browse')" > 📋 Durchsuchen< / button >
2026-04-10 22:24:43 +02:00
< button class = "mode-btn" onclick = "showMode('bookmarks')" > ⭐ Merkliste< / button >
2026-03-28 22:30:24 +01:00
< button class = "mode-btn" onclick = "showMode('tags')" > 🏷️ Tags< / button >
< button class = "mode-btn" onclick = "showMode('upload')" > 📤 Prüfen< / button >
2026-04-10 22:29:55 +02:00
< div class = "hamburger-wrapper" style = "position:relative;display:inline-block;" >
< button class = "mode-btn" onclick = "document.getElementById('hamburger-menu').classList.toggle('open')" style = "font-size:1.2rem;padding:0.3rem 0.6rem;" > ☰< / button >
< div id = "hamburger-menu" class = "hamburger-dropdown" >
< a href = "/auswertungen" > 📈 Auswertungen< / a >
< a href = "/quellen" > 📚 Quellen< / a >
< a href = "/methodik" > 🔍 Methodik< / a >
< hr style = "margin:0.3rem 0;border:none;border-top:1px solid #eee;" >
2026-04-10 23:27:27 +02:00
< button onclick = "event.stopPropagation();document.getElementById('queue-panel').style.display='block';document.getElementById('hamburger-menu').classList.remove('open');loadQueuePanel();" > 📊 Queue< / button >
< button onclick = "event.stopPropagation();document.getElementById('batch-panel').style.display='block';document.getElementById('hamburger-menu').classList.remove('open');" > 📦 Batch-Analyse< / button >
< hr style = "margin:0.3rem 0;border:none;border-top:1px solid #eee;" >
2026-04-10 22:29:55 +02:00
< button id = "auth-btn" onclick = "event.stopPropagation();" > 🔑 Anmelden< / button >
2026-04-10 23:53:05 +02:00
< button onclick = "event.stopPropagation();document.getElementById('register-panel').style.display='block';document.getElementById('hamburger-menu').classList.remove('open');" > 📝 Registrieren< / button >
< button id = "admin-pending-btn" style = "display:none;" onclick = "event.stopPropagation();showPendingUsers();document.getElementById('hamburger-menu').classList.remove('open');" > 👥 Freischaltungen< / button >
2026-04-10 23:56:29 +02:00
< hr style = "margin:0.3rem 0;border:none;border-top:1px solid #eee;" >
< button onclick = "event.stopPropagation();toggleDarkMode();" > 🌙 Dark Mode< / button >
2026-04-10 22:29:55 +02:00
< / div >
< / div >
2026-03-28 22:30:24 +01:00
< / div >
< / header >
< div class = "main-container" id = "browse-mode" >
<!-- Left: List -->
< aside class = "list-panel" >
< div class = "list-header" >
2026-04-09 11:27:29 +02:00
<!--
#16: zwei klar getrennte Suchfelder. Das erste filtert
in der DB der bereits geprüften Anträge (Live, debounced).
Das zweite triggert per Enter oder Button eine Live-
Anfrage gegen den Landtag-Adapter. Beide schreiben in
dieselbe Liste, unterscheiden sich aber visuell und
semantisch klar.
-->
< div class = "search-row" style = "flex-direction: column; gap: 0.4rem;" >
< div style = "display: flex; gap: 0.4rem; width: 100%;" >
< input type = "text" class = "search-box" id = "search-input"
placeholder="📊 Suche in geprüften Anträgen (DB)…"
oninput="debounceSearch(this.value)"
style="flex: 1;">
< / div >
< div style = "display: flex; gap: 0.4rem; width: 100%;" >
< input type = "text" class = "search-box" id = "landtag-search-input"
placeholder="🏛️ Im Landtag suchen (live)…"
onkeydown="if(event.key==='Enter')searchLandtag()"
style="flex: 1;">
< button class = "btn-landtag" id = "btn-landtag" onclick = "searchLandtag()" > 🔍 Suchen< / button >
< / div >
2026-03-28 22:30:24 +01:00
< / div >
< div class = "list-filters" >
< button class = "filter-btn active" data-filter = "all" onclick = "setScoreFilter('all', this)" > Alle< / button >
< button class = "filter-btn" data-filter = "high" onclick = "setScoreFilter('high', this)" > 8-10< / button >
< button class = "filter-btn" data-filter = "mid" onclick = "setScoreFilter('mid', this)" > 5-7< / button >
< button class = "filter-btn" data-filter = "low" onclick = "setScoreFilter('low', this)" > 0-4< / button >
< select id = "partei-filter" onchange = "setParteiFilter(this.value)" style = "padding: 0.25rem 0.5rem; border-radius: 20px; border: 1px solid var(--color-lightgray); font-size: 0.8rem; cursor: pointer;" >
< option value = "" > Alle Parteien< / option >
< / select >
2026-04-10 23:26:05 +02:00
< select id = "sort-select" onchange = "setSortOrder(this.value)" style = "padding: 0.25rem 0.5rem; border-radius: 20px; border: 1px solid var(--color-lightgray); font-size: 0.8rem; cursor: pointer;" >
< option value = "score-desc" > ↓ GWÖ-Score< / option >
< option value = "score-asc" > ↑ GWÖ-Score< / option >
< option value = "date-desc" > ↓ Datum< / option >
< option value = "date-asc" > ↑ Datum< / option >
< option value = "nr-desc" > ↓ Nummer< / option >
< option value = "title-asc" > A-Z Titel< / option >
< / select >
2026-03-28 22:30:24 +01:00
< / div >
< / div >
< div class = "stats-bar" style = "padding: 0.5rem 1rem; gap: 1rem; flex-wrap: wrap; align-items: center;" >
< span style = "font-size: 0.8rem;" > < strong id = "stat-total" > 0< / strong > geprüft · < strong id = "stat-high" > 0< / strong > vorbildlich · Ø < strong id = "stat-avg" > 0< / strong > < / span >
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
< span id = "bundesland-stats" style = "font-size: 0.8rem; color: var(--color-darkgray); display: none; gap: 0.6rem; flex-wrap: wrap;" > < / span >
2026-03-28 22:30:24 +01:00
< span style = "color: var(--color-lightgray);" > |< / span >
< span id = "partei-stats" style = "font-size: 0.8rem; display: flex; gap: 0.75rem; flex-wrap: wrap;" > < / span >
< / div >
< div class = "list-content" id = "list-content" >
< div class = "loading" >
< div class = "spinner" > < / div >
< span > Lade Bewertungen...< / span >
< / div >
< / div >
< / aside >
<!-- Right: Detail -->
< main class = "detail-panel" id = "detail-panel" >
< div class = "detail-placeholder" >
< p > 👈 Wähle einen Antrag aus der Liste< / p >
< / div >
< / main >
< / div >
2026-04-10 22:24:43 +02:00
<!-- Bookmarks / Merkliste Mode -->
< div class = "main-container" id = "bookmarks-mode" style = "display: none;" >
< div class = "list-panel" style = "max-width: 100%;" >
< h2 style = "color: var(--color-blue); margin-bottom: 1rem;" > ⭐ Merkliste< / h2 >
< div id = "bookmarks-content" >
< p style = "color: #888;" > Lade Merkliste...< / p >
< / div >
< / div >
< / div >
2026-03-28 22:30:24 +01:00
<!-- Tags Mode -->
< div class = "main-container" id = "tags-mode" style = "display: none;" >
< aside class = "list-panel" style = "width: 100%; max-width: 400px;" >
< div class = "list-header" >
< h3 style = "margin-bottom: 0.5rem;" > 🏷️ Filter nach Tags< / h3 >
< p style = "font-size: 0.85rem; color: #666; margin-bottom: 1rem;" > Klicke auf Tags um zu filtern. Mehrfachauswahl zeigt Schnittmenge.< / p >
< input type = "text" class = "search-box" id = "tag-search-input" placeholder = "Tags durchsuchen..." oninput = "filterTagCloud(this.value)" >
< div id = "active-tags" style = "margin-top: 0.75rem; display: flex; flex-wrap: wrap; gap: 0.5rem;" > < / div >
< button onclick = "clearTagFilters()" style = "margin-top: 0.75rem; padding: 0.5rem 1rem; background: #dc3545; color: white; border: none; border-radius: 4px; cursor: pointer; display: none;" id = "clear-tags-btn" > ✕ Filter zurücksetzen< / button >
< / div >
< div class = "list-content" id = "tag-cloud" style = "padding: 1rem;" >
< div class = "loading" >
< div class = "spinner" > < / div >
< span > Lade Tags...< / span >
< / div >
< / div >
< / aside >
< main class = "detail-panel" id = "tag-results-panel" >
< div class = "detail-card" >
< h3 style = "margin-bottom: 1rem;" > 📋 Gefilterte Anträge< / h3 >
< div id = "tag-results-list" >
< p style = "color: #888;" > Wähle Tags aus der Wolke um Anträge zu filtern.< / p >
< / div >
< / div >
< / main >
< / div >
<!-- Upload Mode -->
< div class = "main-container" id = "upload-mode" style = "display: none;" >
< div class = "detail-panel" >
< div class = "upload-section" >
< h2 style = "margin-bottom: 1rem; color: var(--color-blue);" > Neuen Antrag prüfen< / h2 >
< div class = "tabs" >
< button class = "tab-btn active" onclick = "showTab('text')" > Text eingeben< / button >
< button class = "tab-btn" onclick = "showTab('file')" > PDF hochladen< / button >
< / div >
< div id = "tab-text" >
< textarea id = "antrag-text" placeholder = "Antragstext hier einfügen..." > < / textarea >
< / div >
< div id = "tab-file" style = "display: none;" >
< div class = "file-drop" onclick = "document.getElementById('file-input').click()" >
< p > 📄 PDF hier ablegen oder klicken< / p >
< input type = "file" id = "file-input" accept = ".pdf" style = "display: none" onchange = "handleFile(this)" >
< p id = "file-name" style = "margin-top: 0.5rem; color: var(--color-blue);" > < / p >
< / div >
< / div >
< div style = "margin-top: 1rem;" >
< label > Bundesland:< / label >
< select id = "bundesland" style = "padding: 0.5rem; margin-left: 0.5rem;" >
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
< option value = "" disabled selected > — Bundesland wählen —< / option >
{% for bl in bundeslaender if bl.code != 'ALL' %}
< option value = "{{ bl.code }}" { % if not bl . active % } disabled { % endif % } >
2026-03-28 22:30:24 +01:00
{{ bl.name }}{% if not bl.active %} (bald){% endif %}
< / option >
{% endfor %}
< / select >
< / div >
< button class = "btn-analyze" id = "analyze-btn" onclick = "startAnalysis()" >
🔍 GWÖ-Analyse starten
< / button >
< div id = "analysis-status" style = "display: none;" >
< div class = "loading" >
< div class = "spinner" > < / div >
< span id = "status-text" > Analyse läuft...< / span >
< / div >
< / div >
< div id = "analysis-result" style = "display: none; margin-top: 1rem;" > < / div >
2026-04-10 23:08:49 +02:00
<!-- Batch - Analyse -->
< div style = "margin-top: 2rem; padding-top: 1.5rem; border-top: 2px solid var(--color-lightgray);" >
< h2 style = "margin-bottom: 0.5rem; color: var(--color-blue);" > 📦 Batch-Analyse< / h2 >
< p style = "font-size: 0.85rem; color: #666; margin-bottom: 1rem;" >
Analysiert automatisch die neuesten ungeprüften Anträge eines Bundeslandes.
< / p >
< div style = "display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;" >
< select id = "batch-bundesland" style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
{% for bl in bundeslaender if bl.code != 'ALL' and bl.active %}
< option value = "{{ bl.code }}" > {{ bl.name }}< / option >
{% endfor %}
< / select >
< select id = "batch-limit" style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
< option value = "5" > 5 Anträge< / option >
< option value = "10" selected > 10 Anträge< / option >
< option value = "20" > 20 Anträge< / option >
< option value = "50" > 50 Anträge< / option >
< / select >
< button id = "batch-btn" onclick = "startBatch()"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung"'}
style="padding:0.4rem 1rem;background:var(--color-green);color:white;border:none;border-radius:4px;cursor:pointer;">
🚀 Batch starten
< / button >
< / div >
< div id = "batch-status" style = "margin-top:0.75rem;font-size:0.85rem;" > < / div >
< / div >
2026-03-28 22:30:24 +01:00
< / div >
< / div >
< / div >
< script >
let allAssessments = [];
let currentScoreFilter = 'all';
let currentParteiFilter = '';
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
let currentBundesland = 'ALL';
2026-03-28 22:30:24 +01:00
let searchTimeout = null;
let isSearching = false;
let selectedTags = new Set();
let allTags = {};
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
let currentUser = null; // #43: Auth-State
2026-04-10 23:26:05 +02:00
let currentSort = localStorage.getItem('sortOrder') || 'score-desc';
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
// #43: Auth prüfen beim Load. Steuert ob "Jetzt prüfen" aktiv ist.
async function initAuth() {
try {
const resp = await fetch('/api/auth/me');
const data = await resp.json();
currentUser = data.authenticated ? data : null;
} catch { currentUser = null; }
updateAuthUI();
}
function updateAuthUI() {
const authBtn = document.getElementById('auth-btn');
if (!authBtn) return;
if (currentUser) {
2026-04-10 21:24:07 +02:00
authBtn.textContent = '✓ Angemeldet (Logout)';
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
authBtn.classList.add('logged-in');
2026-04-10 21:24:07 +02:00
authBtn.style.color = '#889e33';
authBtn.onclick = () => {
// Cookie löschen + Keycloak-Logout
document.cookie = 'access_token=; Max-Age=0; path=/; secure; samesite=lax';
currentUser = null;
updateAuthUI();
loadAssessments(); // Liste neu rendern (Buttons deaktivieren)
};
2026-04-10 23:53:05 +02:00
// Admin-Features anzeigen
if (currentUser.roles & & currentUser.roles.includes('admin')) {
const pendingBtn = document.getElementById('admin-pending-btn');
if (pendingBtn) pendingBtn.style.display = 'block';
}
2026-04-10 21:24:07 +02:00
// Bestehende Liste neu rendern damit Buttons aktiv werden
2026-04-10 23:26:05 +02:00
if (allAssessments.length > 0) renderList(sortAssessments(allAssessments));
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
} else {
authBtn.textContent = '🔑 Anmelden';
2026-04-10 21:24:07 +02:00
authBtn.style.color = '';
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
authBtn.classList.remove('logged-in');
authBtn.onclick = async () => {
const resp = await fetch(`/api/auth/login-url?redirect=${encodeURIComponent(window.location.pathname)}`);
const data = await resp.json();
if (data.url) window.location.href = data.url;
};
}
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-04-10 23:38:37 +02:00
// Matrix-Feld-Info-Modal
function showFieldInfo(field, aspect, rating) {
const labels = {{ matrix_labels | tojson }};
const explains = {{ matrix_explanations | tojson }};
const rowNames = {'A':'Ausgelagerte Betriebe, Lieferant:innen','B':'Finanzpartner:innen, Steuerzahler:innen','C':'Politische Führung, Verwaltung','D':'Bürger:innen und Wirtschaft','E':'Staat, Gesellschaft und Natur'};
const colNames = {1:'Menschenwürde',2:'Solidarität',3:'Ökologische Nachhaltigkeit',4:'Soziale Gerechtigkeit',5:'Transparenz & Mitbestimmung'};
const row = field[0], col = parseInt(field[1]);
const ratingText = rating >= 4 ? '++ stark fördernd' : rating >= 1 ? '+ fördernd' : rating === 0 ? '○ neutral / nicht bewertet' : rating >= -3 ? '− widersprechend' : '− − stark widersprechend';
const ratingColor = rating > 0 ? '#889e33' : rating < 0 ? ' # dc3545 ' : ' # 888 ' ;
const html = `
< div style = "position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;display:flex;justify-content:center;align-items:center;" onclick = "if(event.target===this)this.remove()" >
< div style = "background:white;border-radius:8px;padding:1.5rem;max-width:500px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;" >
< h3 style = "color:var(--color-blue);margin:0;" > ${field}: ${labels[field] || field}< / h3 >
< button onclick = "this.closest('[style*=fixed]').remove()" style = "background:none;border:none;font-size:1.2rem;cursor:pointer;" > ✕< / button >
< / div >
< div style = "font-size:0.85rem;color:#666;margin-bottom:0.75rem;" >
< strong > Zeile ${row}:< / strong > ${rowNames[row] || ''}< br >
< strong > Spalte ${col}:< / strong > ${colNames[col] || ''}
< / div >
< div style = "background:#f8f9fa;padding:0.75rem;border-radius:6px;margin-bottom:0.75rem;font-size:0.9rem;" >
< strong > Was bedeutet das für Bürger:innen?< / strong > < br >
${explains[field] || 'Keine Erklärung verfügbar.'}
< / div >
${aspect ? `< div style = "background:#fffbf0;padding:0.75rem;border-radius:6px;margin-bottom:0.75rem;font-size:0.9rem;border-left:3px solid ${ratingColor};" >
< strong > Bewertung dieses Antrags:< / strong > < br >
${aspect}< br >
< span style = "color:${ratingColor};font-weight:bold;" > Bewertung: ${rating} (${ratingText})< / span >
< / div > ` : '< p style = "color:#aaa;font-size:0.85rem;" > Dieses Feld wird vom Antrag nicht berührt.< / p > '}
< / div >
< / div > `;
document.body.insertAdjacentHTML('beforeend', html);
}
2026-04-10 23:43:57 +02:00
function showColumnInfo(col) {
const names = {1:'Menschenwürde',2:'Solidarität',3:'Ökologische Nachhaltigkeit',4:'Soziale Gerechtigkeit',5:'Transparenz & Mitbestimmung'};
const prinzipien = {1:'Rechtsstaatsprinzip',2:'Gemeinnutz',3:'Umwelt-Verantwortung',4:'Sozialstaatsprinzip',5:'Demokratie'};
const details = {
1: 'Werden Ihre Grundrechte geschützt? Sind Sie vor willkürlicher Behandlung durch Behörden sicher? Haben alle Menschen — unabhängig von Herkunft, Geschlecht oder Religion — die gleichen Rechte?',
2: 'Profitiert die Gemeinschaft? Wenn Ihre Kommune Geld ausgibt, kommt es bei den Menschen an? Werden Vereine, Nachbarschaftshilfe und ehrenamtliches Engagement gestärkt?',
3: 'Können Ihre Kinder noch saubere Luft atmen und in Seen schwimmen? Wird bei Entscheidungen an den Klimawandel gedacht? Gibt es mehr Grün in der Stadt, weniger Versiegelung?',
4: 'Kann sich jede Familie eine warme Wohnung leisten? Bekommen Kinder aus armen Familien die gleichen Bildungschancen? Gibt es bezahlbare Gesundheitsversorgung für alle?',
5: 'Können Sie als Bürger:in mitentscheiden, was in Ihrer Stadt passiert? Werden Sie rechtzeitig informiert? Sind Ratssitzungen öffentlich und verständlich?'
};
document.body.insertAdjacentHTML('beforeend', `
< div style = "position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;display:flex;justify-content:center;align-items:center;" onclick = "if(event.target===this)this.remove()" >
< div style = "background:white;border-radius:8px;padding:1.5rem;max-width:500px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;" >
< h3 style = "color:var(--color-blue);margin:0;" > Wert ${col}: ${names[col]}< / h3 >
< button onclick = "this.closest('[style*=fixed]').remove()" style = "background:none;border:none;font-size:1.2rem;cursor:pointer;" > ✕< / button >
< / div >
< p style = "font-size:0.85rem;color:#666;margin-bottom:0.5rem;" > < strong > Staatsprinzip:< / strong > ${prinzipien[col]}< / p >
< div style = "background:#f8f9fa;padding:0.75rem;border-radius:6px;font-size:0.9rem;" > ${details[col]}< / div >
< / div >
< / div > `);
}
function showRowInfo(row) {
const names = {'A':'Ausgelagerte Betriebe, Lieferant:innen, Dienstleister:innen','B':'Finanzpartner:innen, Geldgeber:innen, Steuerzahler:innen','C':'Politische Führung, Verwaltung, Ehrenamtliche','D':'Bürger:innen und Wirtschaft','E':'Staat, Gesellschaft und Natur'};
const details = {
'A': 'Wenn Ihre Stadt einen Spielplatz baut: Wer liefert das Material? Unter welchen Bedingungen wurde es hergestellt? Wird regional eingekauft — beim Handwerker um die Ecke — oder beim billigsten Anbieter aus dem Ausland? Diese Zeile bewertet, wie verantwortungsvoll die öffentliche Hand einkauft.',
'B': 'Wohin fließen Ihre Steuergelder? Liegt das Geld der Stadt bei einer ethischen Bank oder bei einem Finanzkonzern? Werden Investitionen so getätigt, dass sie der Allgemeinheit nutzen? Hier geht es um den verantwortungsvollen Umgang mit dem Geld, das Sie als Steuerzahler:in einzahlen.',
'C': 'Wie geht Ihre Stadtverwaltung mit den eigenen Mitarbeitenden um? Gibt es faire Bezahlung, Gleichstellung, familienfreundliche Arbeitszeiten? Engagieren sich Ehrenamtliche — und werden sie dafür wertgeschätzt? Diese Zeile bewertet die interne Kultur der öffentlichen Hand.',
'D': 'Das ist die Zeile, die Sie direkt betrifft: Funktioniert der Bus? Ist die Schule gut? Bekommen Sie einen Termin beim Amt? Können Sie sich die Miete leisten? Hier wird bewertet, ob die öffentliche Daseinsvorsorge bei Ihnen ankommt — bei allen, nicht nur bei wenigen.',
'E': 'Denkt Ihre Kommune über den eigenen Tellerrand hinaus? Wird beim Straßenbau an den Klimawandel gedacht? Gibt es Partnerschaften mit strukturschwachen Regionen? Hier wird bewertet, ob Entscheidungen auch für künftige Generationen und Menschen anderswo gut sind.'
};
document.body.insertAdjacentHTML('beforeend', `
< div style = "position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;display:flex;justify-content:center;align-items:center;" onclick = "if(event.target===this)this.remove()" >
< div style = "background:white;border-radius:8px;padding:1.5rem;max-width:500px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;" >
< h3 style = "color:var(--color-blue);margin:0;" > Gruppe ${row}: ${names[row]}< / h3 >
< button onclick = "this.closest('[style*=fixed]').remove()" style = "background:none;border:none;font-size:1.2rem;cursor:pointer;" > ✕< / button >
< / div >
< div style = "background:#f8f9fa;padding:0.75rem;border-radius:6px;font-size:0.9rem;" > ${details[row]}< / div >
< / div >
< / div > `);
}
2026-04-10 23:26:05 +02:00
// Sortierung (#100)
function setSortOrder(order) {
currentSort = order;
localStorage.setItem('sortOrder', order);
applyFilters();
}
function sortAssessments(items) {
const sorted = [...items];
switch (currentSort) {
case 'score-desc': return sorted.sort((a, b) => (b.gwoeScore || 0) - (a.gwoeScore || 0));
case 'score-asc': return sorted.sort((a, b) => (a.gwoeScore || 0) - (b.gwoeScore || 0));
case 'date-desc': return sorted.sort((a, b) => (b.datum || '').localeCompare(a.datum || ''));
case 'date-asc': return sorted.sort((a, b) => (a.datum || '').localeCompare(b.datum || ''));
case 'nr-desc': return sorted.sort((a, b) => {
const na = parseInt((a.drucksache || '').split('/')[1]) || 0;
const nb = parseInt((b.drucksache || '').split('/')[1]) || 0;
return nb - na;
});
case 'title-asc': return sorted.sort((a, b) => (a.title || '').localeCompare(b.title || '', 'de'));
default: return sorted;
}
}
2026-04-10 23:56:29 +02:00
// Dark Mode (#114)
function toggleDarkMode() {
const current = document.documentElement.getAttribute('data-theme');
const next = current === 'dark' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', next);
localStorage.setItem('theme', next);
}
// Beim Laden: System-Präferenz oder gespeicherte Wahl
(function() {
const saved = localStorage.getItem('theme');
if (saved) {
document.documentElement.setAttribute('data-theme', saved);
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
2026-04-10 22:29:55 +02:00
// Hamburger-Menü schließen bei Klick außerhalb
document.addEventListener('click', (e) => {
const menu = document.getElementById('hamburger-menu');
if (menu & & !e.target.closest('.hamburger-wrapper')) {
menu.classList.remove('open');
}
});
2026-04-10 22:15:13 +02:00
// Partei-Normalisierung (global, für Stats + Labels)
function normalizePartei(f) {
const u = f.toUpperCase();
if (u === 'AFD') return 'AfD';
if (u === 'GRÜNE' || u === 'GRUENE' || u === 'BÜNDNIS 90/DIE GRÜNEN') return 'GRÜNE';
if (u === 'DIE LINKE') return 'LINKE';
return f;
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
// Map code → parlament_name, vom Backend mit dem Initial-Render geliefert.
// Wird im Detail-Header und im Listen-Item-Badge-Tooltip verwendet.
const PARLAMENT_NAMES = {{ parlament_names | tojson }};
// Load assessments on page load — localStorage-Auswahl wiederherstellen
2026-03-28 22:30:24 +01:00
document.addEventListener('DOMContentLoaded', () => {
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
initAuth(); // #43: Auth-State prüfen
2026-04-10 23:26:05 +02:00
// Sort-Auswahl aus localStorage wiederherstellen
const savedSort = localStorage.getItem('sortOrder');
if (savedSort) {
document.getElementById('sort-select').value = savedSort;
currentSort = savedSort;
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
const saved = localStorage.getItem('selectedBundesland');
const select = document.getElementById('bundesland-select');
if (saved) {
// Validieren: existiert die Option?
const exists = Array.from(select.options).some(
o => o.value === saved & & !o.disabled
);
if (exists) {
currentBundesland = saved;
select.value = saved;
}
}
// Modus-Klasse für CSS (Badges aus/an)
document.getElementById('list-content').dataset.mode =
(currentBundesland === 'ALL') ? 'all' : 'single';
// Landtag-Button-State für Initial-Auswahl
const btnLandtag = document.getElementById('btn-landtag');
if (currentBundesland === 'ALL') {
btnLandtag.disabled = true;
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
}
2026-03-28 22:30:24 +01:00
loadAssessments();
});
function buildParteienFilter() {
// Alle Fraktionen sammeln
const parteien = new Set();
allAssessments.forEach(a => {
(a.fraktionen || []).forEach(f => parteien.add(f));
});
const select = document.getElementById('partei-filter');
select.innerHTML = '< option value = "" > Alle Parteien< / option > ';
Array.from(parteien).sort().forEach(p => {
select.innerHTML += `< option value = "${p}" > ${p}< / option > `;
});
}
function buildTagCloud() {
allTags = {};
allAssessments.forEach(a => {
(a.themen || []).forEach(tag => {
allTags[tag] = (allTags[tag] || 0) + 1;
});
});
renderTagCloud();
}
function renderTagCloud(filter = '') {
const container = document.getElementById('tag-cloud');
const filterLower = filter.toLowerCase();
// Sortiert nach Häufigkeit
const sorted = Object.entries(allTags)
.filter(([tag]) => tag.toLowerCase().includes(filterLower))
.sort((a, b) => b[1] - a[1]);
if (sorted.length === 0) {
container.innerHTML = '< p style = "color: #888;" > Keine Tags gefunden< / p > ';
return;
}
const maxCount = Math.max(...sorted.map(([, c]) => c));
container.innerHTML = '< div style = "display: flex; flex-wrap: wrap; gap: 0.5rem;" > ' +
sorted.map(([tag, count]) => {
const size = 0.75 + (count / maxCount) * 0.75;
const isActive = selectedTags.has(tag);
return `< button
onclick="toggleTag('${tag.replace(/'/g, "\\'")}')"
style="
font-size: ${size}rem;
padding: 0.3rem 0.6rem;
border-radius: 20px;
border: 1px solid ${isActive ? 'var(--color-blue)' : 'var(--color-lightgray)'};
background: ${isActive ? 'var(--color-blue)' : 'white'};
color: ${isActive ? 'white' : 'var(--color-darkgray)'};
cursor: pointer;
"
title="${count} Anträge"
>${tag} < span style = "font-size: 0.7rem; opacity: 0.7;" > (${count})< / span > < / button > `;
}).join('') +
'< / div > ';
}
function filterTagCloud(query) {
renderTagCloud(query);
}
function toggleTag(tag) {
if (selectedTags.has(tag)) {
selectedTags.delete(tag);
} else {
selectedTags.add(tag);
}
updateTagFilterUI();
filterByTags();
}
function updateTagFilterUI() {
const container = document.getElementById('active-tags');
const clearBtn = document.getElementById('clear-tags-btn');
if (selectedTags.size === 0) {
container.innerHTML = '';
clearBtn.style.display = 'none';
} else {
container.innerHTML = Array.from(selectedTags).map(tag =>
`< span style = "padding: 0.25rem 0.5rem; background: var(--color-blue); color: white; border-radius: 4px; font-size: 0.85rem;" >
${tag} < span style = "cursor: pointer; margin-left: 0.25rem;" onclick = "toggleTag('${tag.replace(/'/g, " \ \ ' " ) } ' ) " > × < / span >
< / span > `
).join('');
clearBtn.style.display = 'block';
}
renderTagCloud(document.getElementById('tag-search-input')?.value || '');
}
function clearTagFilters() {
selectedTags.clear();
updateTagFilterUI();
filterByTags();
}
function filterByTags() {
const resultsContainer = document.getElementById('tag-results-list');
if (selectedTags.size === 0) {
resultsContainer.innerHTML = '< p style = "color: #888;" > Wähle Tags aus der Wolke um Anträge zu filtern.< / p > ';
return;
}
// Schnittmenge: Anträge müssen ALLE ausgewählten Tags haben
const filtered = allAssessments.filter(a => {
const tags = new Set(a.themen || []);
return Array.from(selectedTags).every(t => tags.has(t));
});
if (filtered.length === 0) {
resultsContainer.innerHTML = '< p style = "color: #888;" > Keine Anträge mit dieser Tag-Kombination gefunden.< / p > ';
return;
}
resultsContainer.innerHTML = `
< p style = "margin-bottom: 1rem; color: #666;" > ${filtered.length} Anträge gefunden< / p >
${filtered.map(item => `
< div style = "padding: 0.75rem; border-bottom: 1px solid var(--color-lightgray); cursor: pointer;" onclick = "showMode('browse'); setTimeout(() => showDetail('${item.drucksache}'), 100);" >
< div style = "display: flex; justify-content: space-between; align-items: center;" >
< span style = "font-weight: bold; color: var(--color-blue);" > ${item.drucksache}< / span >
< span style = "padding: 0.1rem 0.5rem; border-radius: 4px; font-size: 0.85rem; ${item.gwoeScore >= 8 ? 'background: #155724; color: white;' : item.gwoeScore >= 5 ? 'background: #889e33; color: white;' : 'background: #dc3545; color: white;'}" > ${item.gwoeScore}/10< / span >
< / div >
< div style = "font-size: 0.9rem; margin-top: 0.25rem;" > ${item.title || 'Ohne Titel'}< / div >
< div style = "font-size: 0.8rem; color: #888;" > ${(item.fraktionen || []).join(', ')}< / div >
< / div >
`).join('')}
`;
}
async function loadAssessments() {
try {
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
const url = `/api/assessments?bundesland=${encodeURIComponent(currentBundesland)}`;
const resp = await fetch(url);
2026-03-28 22:30:24 +01:00
allAssessments = await resp.json();
updateStats();
2026-04-10 23:26:05 +02:00
renderList(sortAssessments(allAssessments));
2026-03-28 22:30:24 +01:00
buildParteienFilter();
buildTagCloud();
} catch (e) {
2026-04-10 19:55:08 +02:00
console.error('loadAssessments error:', e);
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
document.getElementById('list-content').innerHTML =
2026-04-10 19:55:08 +02:00
`< p style = "padding: 1rem; color: #c00;" > Fehler beim Laden: ${e.message}< / p > `;
2026-03-28 22:30:24 +01:00
}
}
function updateStats() {
const checked = allAssessments.filter(a => a.status !== 'unchecked').length;
const high = allAssessments.filter(a => a.gwoeScore >= 8).length;
const avg = checked > 0 ? (allAssessments.filter(a => a.gwoeScore != null).reduce((s, a) => s + (a.gwoeScore || 0), 0) / checked).toFixed(1) : 0;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
document.getElementById('stat-total').textContent = checked;
document.getElementById('stat-high').textContent = high;
document.getElementById('stat-avg').textContent = avg;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
// Pro-Bundesland-Aufschlüsselung — nur im Bundesweit-Modus, und nur
// wenn tatsächlich mehr als ein Bundesland in der Liste vorkommt.
const blContainer = document.getElementById('bundesland-stats');
if (currentBundesland === 'ALL') {
const blStats = {};
allAssessments.forEach(a => {
if (a.gwoeScore == null || !a.bundesland) return;
if (!blStats[a.bundesland]) blStats[a.bundesland] = { sum: 0, count: 0 };
blStats[a.bundesland].sum += a.gwoeScore;
blStats[a.bundesland].count += 1;
});
const codes = Object.keys(blStats);
if (codes.length > 1) {
const sortedBl = codes
.map(c => ({ code: c, avg: blStats[c].sum / blStats[c].count, count: blStats[c].count }))
.sort((a, b) => b.avg - a.avg);
blContainer.innerHTML = sortedBl.map(b =>
`< span title = "${PARLAMENT_NAMES[b.code] || b.code}" > Ø < strong > ${b.code}< / strong > ${b.avg.toFixed(1)} < span style = "color:#888" > (n=${b.count})< / span > < / span > `
).join(' · ');
blContainer.style.display = 'inline-flex';
} else {
blContainer.style.display = 'none';
blContainer.innerHTML = '';
}
} else {
blContainer.style.display = 'none';
blContainer.innerHTML = '';
}
2026-04-10 22:15:13 +02:00
// Partei-Durchschnitte berechnen (Normalisierung via globaler normalizePartei)
2026-03-28 22:30:24 +01:00
const parteiStats = {};
allAssessments.forEach(a => {
if (a.gwoeScore == null) return;
(a.fraktionen || []).forEach(f => {
2026-04-10 22:13:30 +02:00
const norm = normalizePartei(f);
if (!parteiStats[norm]) parteiStats[norm] = { sum: 0, count: 0 };
parteiStats[norm].sum += a.gwoeScore;
parteiStats[norm].count += 1;
2026-03-28 22:30:24 +01:00
});
});
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
// Sortiert nach Durchschnitt (absteigend)
const sorted = Object.entries(parteiStats)
.map(([partei, data]) => ({ partei, avg: data.sum / data.count, count: data.count }))
.sort((a, b) => b.avg - a.avg);
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
const container = document.getElementById('partei-stats');
container.innerHTML = sorted.map(p => {
const color = p.avg >= 7 ? '#889e33' : p.avg >= 4 ? '#fd7e14' : '#dc3545';
return `< span style = "color: ${color};" > < strong > ${p.partei}< / strong > ${p.avg.toFixed(1)}< / span > `;
}).join('');
}
function renderList(items) {
const container = document.getElementById('list-content');
if (items.length === 0) {
container.innerHTML = '< p style = "padding: 1rem; color: #888;" > Keine Ergebnisse< / p > ';
return;
}
container.innerHTML = items.map(item => {
const isUnchecked = item.status === 'unchecked';
// Skala 0-10
const scoreClass = isUnchecked ? 'status-unchecked' :
item.gwoeScore >= 8 ? 'score-high' :
item.gwoeScore >= 5 ? 'score-mid' :
item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
const fraktionen = (item.fraktionen || []).join(', ') || 'k.A.';
const themen = (item.themen || []).slice(0, 3);
const scoreText = isUnchecked ? '⏳' : `${item.gwoeScore}/10`;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
const blBadge = item.bundesland
? `< span class = "bl-badge" title = "${PARLAMENT_NAMES[item.bundesland] || item.bundesland}" > ${item.bundesland}< / span > `
: '';
2026-03-28 22:30:24 +01:00
return `
< div class = "list-item ${isUnchecked ? 'unchecked' : ''}" data-drucksache = "${item.drucksache}" onclick = "${isUnchecked ? '' : `showDetail('${item.drucksache}')`}" >
< div class = "list-item-header" >
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
< span class = "list-item-id" > ${blBadge}${item.drucksache}< / span >
2026-03-28 22:30:24 +01:00
< span class = "list-item-score ${scoreClass}" > ${scoreText}< / span >
< / div >
2026-04-10 22:13:30 +02:00
< div class = "list-item-title" > ${(item.title || 'Ohne Titel').length > 80 ? (item.title.substring(0, 80) + '…') : (item.title || 'Ohne Titel')}< / div >
2026-03-28 22:30:24 +01:00
< div class = "list-item-meta" > ${fraktionen} · ${item.datum || ''}< / div >
${isUnchecked ? `
#43 Keycloak SSO: JWT-Middleware + UI-Guiding
Auth-Schicht vorbereitet — Dev-Modus (KEYCLOAK_URL leer) lässt alles
durch, Prod-Modus (ENV gesetzt) validiert JWT gegen Keycloak-JWKS.
Backend (app/auth.py):
- JWKS-Cache mit 1h TTL (async httpx fetch)
- get_current_user: Optional, gibt User-Dict oder None
- require_auth: Pflicht, gibt User-Dict oder HTTP 401
- keycloak_login_url: Baut die OIDC-Login-URL
- _is_auth_enabled: prüft ob alle 3 ENV-Vars gesetzt sind
Abgesicherte POST-Endpoints:
- POST /analyze → Depends(require_auth)
- POST /api/analyze-drucksache → Depends(require_auth)
- POST /api/programme/index → Depends(require_auth)
Neue Endpoints:
- GET /api/auth/me → {authenticated, sub, email, name, roles} oder {authenticated: false}
- GET /api/auth/login-url → {enabled, url} für Keycloak-Redirect
Frontend (index.html):
- initAuth() beim DOMContentLoaded → prüft /api/auth/me
- "Anmelden"-Button im Header (neben "Quellen")
- "Jetzt prüfen"-Button: disabled + Tooltip "Nur nach Anmeldung
verfügbar" wenn nicht eingeloggt; aktiv wenn eingeloggt
- currentUser-State steuert Button-Zustände
Dev-Modus: Solange KEYCLOAK_URL nicht gesetzt ist (lokale Dev, aktueller
Prod-Stand), sind alle Endpoints offen wie bisher. Kein Breaking Change.
Dependency: python-jose[cryptography]>=3.3.0 in requirements.txt.
Tests: 194/194 grün (auth.py hat keine Seiteneffekte im Import).
Refs: #43
2026-04-10 14:28:57 +02:00
< button class = "btn-check-now"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung verfügbar" style="opacity:0.5;cursor:not-allowed;"'}
onclick="event.stopPropagation(); checkNow('${item.drucksache}', this)">
2026-03-28 22:30:24 +01:00
🔍 Jetzt prüfen
< / button >
` : `
< div class = "list-item-tags" >
${themen.map(t => `< span class = "tag" > ${t}< / span > `).join('')}
< / div >
`}
< / div >
`;
}).join('');
}
function debounceSearch(query) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => performSearch(query), 300);
}
async function performSearch(query) {
if (query.length < 2 ) {
// Show all from DB with current filters
applyAllFilters();
return;
}
isSearching = true;
document.getElementById('list-content').innerHTML = `
< div class = "loading" >
< div class = "spinner" > < / div >
< span > Suche...< / span >
< / div >
`;
try {
const resp = await fetch(`/api/search?q=${encodeURIComponent(query)}&bundesland=${currentBundesland}`);
let results = await resp.json();
// Score-Filter anwenden
if (currentScoreFilter !== 'all') {
results = applyScoreFilter(results, currentScoreFilter);
}
// Partei-Filter anwenden
if (currentParteiFilter) {
results = results.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
renderList(results);
} catch (e) {
document.getElementById('list-content').innerHTML =
'< p style = "padding: 1rem; color: #888;" > Suchfehler< / p > ';
}
isSearching = false;
}
async function checkNow(drucksache, btn) {
btn.disabled = true;
btn.textContent = '⏳ Prüfe...';
try {
const resp = await fetch('/api/analyze-drucksache', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}& bundesland=${currentBundesland}`
});
const data = await resp.json();
if (data.status === 'queued') {
btn.textContent = '⏳ Analysiere...';
pollAnalysis(data.job_id, drucksache, btn);
} else if (data.status === 'already_checked') {
btn.textContent = '✓ Bereits geprüft';
setTimeout(() => loadAssessments(), 500);
} else {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
} catch (e) {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
}
2026-04-10 23:08:49 +02:00
// ─── Batch-Analyse ──────────────────────────────────────────
async function startBatch() {
const bl = document.getElementById('batch-bundesland').value;
const limit = document.getElementById('batch-limit').value;
const btn = document.getElementById('batch-btn');
const status = document.getElementById('batch-status');
btn.disabled = true;
btn.textContent = '⏳ Wird gestartet...';
status.innerHTML = '';
try {
const resp = await fetch('/api/batch-analyze', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `bundesland=${bl}& limit=${limit}`
});
if (resp.status === 401) {
status.innerHTML = '< span style = "color:#dc3545;" > 🔒 Bitte zuerst anmelden.< / span > ';
btn.disabled = false; btn.textContent = '🚀 Batch starten';
return;
}
const data = await resp.json();
if (data.status === 'batch_enqueued') {
status.innerHTML = `
< span style = "color:var(--color-green);" > ✓ ${data.enqueued} Anträge in die Queue eingereiht< / span >
${data.skipped_existing > 0 ? `< br > < span style = "color:#888;" > ${data.skipped_existing} bereits bewertet (übersprungen)< / span > ` : ''}
< br > < span style = "color:#888;" > Die Analyse läuft im Hintergrund. Ergebnisse erscheinen nach und nach in der Liste.< / span >
`;
// Queue-Status pollen
pollBatchQueue(status);
} else {
status.innerHTML = `< span style = "color:#dc3545;" > ❌ ${data.detail || 'Fehler'}< / span > `;
}
} catch (e) {
status.innerHTML = `< span style = "color:#dc3545;" > ❌ ${e.message}< / span > `;
}
btn.disabled = false; btn.textContent = '🚀 Batch starten';
}
async function pollBatchQueue(statusEl) {
for (let i = 0; i < 200 ; i + + ) {
2026-04-10 23:15:42 +02:00
await new Promise(r => setTimeout(r, 3000));
2026-04-10 23:08:49 +02:00
try {
const qs = await fetch('/api/queue/status').then(r => r.json());
2026-04-10 23:15:42 +02:00
// Job-Tabelle rendern
const jobs = qs.jobs || [];
const jobsHtml = jobs.length > 0 ? `
< table style = "width:100%;font-size:0.8rem;border-collapse:collapse;margin-top:0.5rem;" >
< tr style = "color:#888;" > < td > Drucksache< / td > < td > Status< / td > < td > Dauer< / td > < / tr >
${jobs.map(j => {
const statusIcon = j.status === 'completed' ? '✅' : j.status === 'processing' ? '⏳' : j.status === 'failed' ? '❌' : '⏸';
const dur = j.duration ? j.duration + 's' : '';
const link = j.status === 'completed' ? `< a href = "#" onclick = "showMode('browse');setTimeout(()=>showDetail('${j.drucksache}'),100);return false;" style = "color:var(--color-blue);" > ${j.drucksache}< / a > ` : j.drucksache;
return `< tr style = "border-top:1px solid #f0f0f0;" > < td > ${link}< / td > < td > ${statusIcon} ${j.status}< / td > < td > ${dur}< / td > < / tr > `;
}).join('')}
< / table > ` : '';
// Fortschrittsbalken
const completed = jobs.filter(j => j.status === 'completed').length;
const total = jobs.length;
const pct = total > 0 ? Math.round(completed / total * 100) : 0;
statusEl.innerHTML = `
< div style = "margin:0.5rem 0;" >
< div style = "background:#eee;border-radius:4px;height:8px;overflow:hidden;" >
< div style = "background:var(--color-green);height:100%;width:${pct}%;transition:width 0.5s;" > < / div >
< / div >
< span style = "font-size:0.8rem;color:#888;" > ${completed}/${total} fertig · ${qs.concurrency} Worker · ~${Math.round(qs.estimated_wait_seconds/60)} Min.< / span >
< / div >
${jobsHtml}
`;
if (qs.pending === 0 & & completed > 0) {
statusEl.innerHTML += `< br > < span style = "color:var(--color-green);" > ✓ Alle Jobs abgeschlossen< / span > `;
loadAssessments();
2026-04-10 23:08:49 +02:00
return;
}
} catch { break; }
}
}
2026-04-10 22:24:43 +02:00
// ─── Merkliste (#94) ────────────────────────────────────────
async function loadBookmarksList() {
const container = document.getElementById('bookmarks-content');
if (!currentUser) {
container.innerHTML = '< p style = "color:#888;" > Bitte anmelden um die Merkliste zu sehen.< / p > ';
return;
}
container.innerHTML = '< p style = "color:#888;" > Lade...< / p > ';
try {
const bookmarkIds = await fetch('/api/bookmarks').then(r => r.json());
if (bookmarkIds.length === 0) {
container.innerHTML = '< p style = "color:#888;" > Keine gemerkten Anträge. Klicke auf "🔖 Merken" bei einem Antrag.< / p > ';
return;
}
// Finde die Assessment-Daten für die gebookmarkten Drucksachen
const bookmarked = allAssessments.filter(a => bookmarkIds.includes(a.drucksache));
if (bookmarked.length === 0) {
container.innerHTML = '< p style = "color:#888;" > Gemerkte Anträge nicht in der aktuellen Auswahl gefunden. Wechsle zu "Bundesweit".< / p > ';
return;
}
container.innerHTML = bookmarked.map(item => {
const scoreClass = item.gwoeScore >= 8 ? 'score-high' : item.gwoeScore >= 5 ? 'score-mid' : item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
const blBadge = item.bundesland ? `< span class = "bl-badge" > ${item.bundesland}< / span > ` : '';
return `
< div class = "list-item" onclick = "showMode('browse'); setTimeout(() => showDetail('${item.drucksache}'), 100);" style = "cursor:pointer;" >
< div class = "list-item-header" >
< span class = "list-item-id" > ${blBadge}${item.drucksache}< / span >
< span class = "list-item-score ${scoreClass}" > ${item.gwoeScore}/10< / span >
< / div >
< div class = "list-item-title" > ${(item.title || '').substring(0, 80)}${(item.title || '').length > 80 ? '…' : ''}< / div >
< div class = "list-item-meta" > ${(item.fraktionen || []).join(', ')} · ${item.datum || ''}< / div >
< / div > `;
}).join('');
} catch (e) {
container.innerHTML = '< p style = "color:#c00;" > Fehler beim Laden der Merkliste.< / p > ';
}
}
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
// ─── Bookmarks + Comments (#94) ─────────────────────────────
async function toggleBookmark(drucksache, btn) {
const resp = await fetch('/api/bookmark', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}`
});
if (resp.ok) {
const data = await resp.json();
btn.textContent = data.bookmarked ? '⭐ Gemerkt' : '🔖 Merken';
btn.style.background = data.bookmarked ? '#fff3cd' : 'none';
}
}
async function loadBookmarkState(drucksache) {
if (!currentUser) return;
const bookmarks = await fetch('/api/bookmarks').then(r => r.json());
const btn = document.getElementById('bookmark-btn-' + drucksache.replace('/', '-'));
if (btn & & bookmarks.includes(drucksache)) {
btn.textContent = '⭐ Gemerkt';
btn.style.background = '#fff3cd';
}
}
async function loadComments(drucksache) {
const container = document.getElementById('comments-' + drucksache.replace('/', '-'));
if (!container) return;
try {
const comments = await fetch(`/api/comments?drucksache=${encodeURIComponent(drucksache)}`).then(r => r.json());
2026-04-10 22:40:27 +02:00
// Client-seitig filtern nach Sichtbarkeit
const visible = comments.filter(c => {
if (c.visibility === 'all') return true;
if (!currentUser) return false;
if (c.visibility === 'authenticated') return true;
if (c.visibility === 'private') return c.user_id === currentUser.sub;
// group:XYZ — TODO: gegen Keycloak-Gruppen prüfen
if (c.visibility?.startsWith('group:')) return true; // Platzhalter
return false;
});
if (visible.length === 0) {
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
container.innerHTML = '< span style = "color:#aaa;font-size:0.85rem;" > Noch keine Kommentare.< / span > ';
return;
}
2026-04-10 22:40:27 +02:00
const visBadge = (v) => {
if (v === 'private') return '< span style = "font-size:0.7rem;background:#f0f0f0;padding:0.1rem 0.3rem;border-radius:2px;" title = "Nur für dich sichtbar" > 👤< / span > ';
if (v === 'authenticated') return '< span style = "font-size:0.7rem;background:#e8f4f8;padding:0.1rem 0.3rem;border-radius:2px;" title = "Nur für angemeldete Nutzer" > 🔒< / span > ';
if (v?.startsWith('group:')) return `< span style = "font-size:0.7rem;background:#f0e6ff;padding:0.1rem 0.3rem;border-radius:2px;" title = "Gruppe: ${v.substring(6)}" > 👥 ${v.substring(6)}< / span > `;
return '< span style = "font-size:0.7rem;background:#e8ffe8;padding:0.1rem 0.3rem;border-radius:2px;" title = "Öffentlich sichtbar" > 🌐< / span > ';
};
container.innerHTML = visible.map(c => `
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
< div style = "padding:0.4rem 0;border-bottom:1px solid #f0f0f0;font-size:0.85rem;" >
< strong > ${c.user_name || 'Anonym'}< / strong >
2026-04-10 22:40:27 +02:00
${visBadge(c.visibility)}
< span style = "color:#aaa;font-size:0.75rem;margin-left:0.3rem;" > ${new Date(c.created_at).toLocaleString('de-DE')}< / span >
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
${currentUser & & currentUser.sub === c.user_id ? `< button onclick = "deleteCommentUI(${c.id},'${drucksache}')" style = "float:right;background:none;border:none;color:#dc3545;cursor:pointer;font-size:0.75rem;" > ✕< / button > ` : ''}
< div style = "margin-top:0.2rem;" > ${c.text}< / div >
< / div >
`).join('');
} catch { container.innerHTML = ''; }
}
async function addCommentUI(drucksache) {
2026-04-10 22:40:27 +02:00
const safeDrs = drucksache.replace('/', '-');
const input = document.getElementById('comment-input-' + safeDrs);
const visSelect = document.getElementById('comment-visibility-' + safeDrs);
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
if (!input || !input.value.trim()) return;
2026-04-10 22:40:27 +02:00
const visibility = visSelect ? visSelect.value : 'all';
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
await fetch('/api/comment', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
2026-04-10 22:40:27 +02:00
body: `drucksache=${encodeURIComponent(drucksache)}& text=${encodeURIComponent(input.value)}& visibility=${visibility}`
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
});
input.value = '';
loadComments(drucksache);
}
async function deleteCommentUI(commentId, drucksache) {
await fetch(`/api/comment/${commentId}`, {method: 'DELETE'});
loadComments(drucksache);
}
2026-04-10 21:10:33 +02:00
async function reAnalyze(drucksache, bundesland, btn) {
2026-04-10 21:24:07 +02:00
if (!currentUser) {
alert('Bitte zuerst anmelden.');
return;
}
2026-04-10 21:10:33 +02:00
btn.disabled = true;
2026-04-10 21:24:07 +02:00
btn.style.background = '#ffc107';
btn.textContent = '⏳ Lösche alte Bewertung...';
2026-04-10 21:10:33 +02:00
try {
// Altes Assessment löschen
2026-04-10 21:24:07 +02:00
const delResp = await fetch(`/api/assessment/delete?drucksache=${encodeURIComponent(drucksache)}`, {method: 'DELETE'});
if (delResp.status === 401) {
btn.textContent = '🔒 Nicht angemeldet';
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
return;
}
btn.textContent = '⏳ Analyse gestartet...';
2026-04-10 21:10:33 +02:00
// Neue Analyse enqueuen
const resp = await fetch('/api/analyze-drucksache', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: `drucksache=${encodeURIComponent(drucksache)}& bundesland=${encodeURIComponent(bundesland)}`
});
2026-04-10 21:24:07 +02:00
if (resp.status === 401) {
btn.textContent = '🔒 Nicht angemeldet';
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
return;
}
2026-04-10 21:10:33 +02:00
const data = await resp.json();
if (data.status === 'queued') {
2026-04-10 21:24:07 +02:00
btn.textContent = '⏳ Wird analysiert...';
btn.style.background = '#009da5';
2026-04-10 21:10:33 +02:00
pollAnalysis(data.job_id, drucksache, btn);
} else {
2026-04-10 21:24:07 +02:00
btn.textContent = '❌ ' + (data.detail || 'Fehler');
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
2026-04-10 21:10:33 +02:00
}
} catch (e) {
2026-04-10 21:24:07 +02:00
btn.textContent = '❌ ' + e.message;
btn.style.background = '#dc3545';
setTimeout(() => { btn.textContent = '🔄 Neu bewerten'; btn.style.background = '#6c757d'; btn.disabled = false; }, 3000);
2026-04-10 21:10:33 +02:00
}
}
2026-03-28 22:30:24 +01:00
async function pollAnalysis(jobId, drucksache, btn) {
try {
const resp = await fetch(`/status/${jobId}`);
const data = await resp.json();
if (data.status === 'completed') {
btn.textContent = '✓ Geprüft';
btn.style.background = '#889e33'; // Green
// Update this item in current list to show as checked
const listItem = btn.closest('.list-item');
if (listItem) {
listItem.classList.remove('unchecked');
listItem.onclick = () => showDetail(drucksache);
}
// Reload assessments in background (for internal list)
loadAssessments();
// Show detail for this item
setTimeout(() => showDetail(drucksache), 500);
} else if (data.status === 'failed') {
btn.textContent = '✗ Fehlgeschlagen';
btn.disabled = false;
} else {
setTimeout(() => pollAnalysis(jobId, drucksache, btn), 2000);
}
} catch (e) {
btn.textContent = '✗ Fehler';
btn.disabled = false;
}
}
function changeBundesland(code) {
currentBundesland = code;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
localStorage.setItem('selectedBundesland', code);
// Filter zurücksetzen — Parteien & Tags pro Bundesland unterschiedlich,
// ein "LINKE"-Filter aus LSA würde in NRW eine leere Liste zeigen.
currentScoreFilter = 'all';
currentParteiFilter = '';
selectedTags.clear();
2026-03-28 22:30:24 +01:00
document.getElementById('search-input').value = '';
2026-04-09 11:27:29 +02:00
const landtagInput = document.getElementById('landtag-search-input');
if (landtagInput) landtagInput.value = '';
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
document.querySelectorAll('.filter-btn').forEach(b => {
b.classList.toggle('active', b.dataset.filter === 'all');
});
const parteiSelect = document.getElementById('partei-filter');
if (parteiSelect) parteiSelect.value = '';
// Upload-Mode-Dropdown synchronisieren. Bei "ALL" KEIN automatischer
// Default — der User soll im Upload-Form bewusst ein Bundesland wählen.
const uploadDropdown = document.getElementById('bundesland');
if (uploadDropdown) {
if (code === 'ALL') {
uploadDropdown.value = '';
} else {
uploadDropdown.value = code;
}
}
// Landtag-Suche-Button im Bundesweit-Modus deaktivieren
const btnLandtag = document.getElementById('btn-landtag');
if (code === 'ALL') {
btnLandtag.disabled = true;
btnLandtag.title = 'Landtag-Suche nur mit konkretem Bundesland möglich';
} else {
btnLandtag.disabled = false;
btnLandtag.title = '';
}
// Modus-Klasse für CSS (Badges aus/an im Single-Modus)
document.getElementById('list-content').dataset.mode =
(code === 'ALL') ? 'all' : 'single';
2026-03-28 22:30:24 +01:00
loadAssessments();
}
async function searchLandtag() {
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
if (currentBundesland === 'ALL') {
alert('Landtag-Suche ist nur mit Auswahl eines konkreten Bundeslands möglich.\nBitte oben ein Bundesland auswählen.');
return;
}
2026-04-09 11:27:29 +02:00
// #16: Landtag-Suche zieht aus dem dedizierten Landtag-Input,
// nicht mehr aus dem DB-Suchfeld.
const query = document.getElementById('landtag-search-input').value.trim();
2026-03-28 22:30:24 +01:00
if (query.length < 2 ) {
2026-04-09 11:27:29 +02:00
alert('Bitte mindestens 2 Zeichen ins Landtag-Suchfeld eingeben');
2026-03-28 22:30:24 +01:00
return;
}
const btn = document.getElementById('btn-landtag');
btn.disabled = true;
btn.textContent = '⏳ Suche...';
document.getElementById('list-content').innerHTML = `
< div class = "loading" >
< div class = "spinner" > < / div >
< span > Suche im Landtag ${currentBundesland}...< / span >
< / div >
`;
try {
const resp = await fetch(`/api/search-landtag?q=${encodeURIComponent(query)}&bundesland=${currentBundesland}`);
const results = await resp.json();
if (results.error) {
document.getElementById('list-content').innerHTML = `
< p style = "padding: 1rem; color: #888;" > ${results.error}< / p >
`;
} else if (results.length === 0) {
document.getElementById('list-content').innerHTML = `
< p style = "padding: 1rem; color: #888;" > Keine Treffer im Landtag für "${query}"< / p >
`;
} else {
// Merge with checked status from DB
const checkedIds = new Set(allAssessments.map(a => a.drucksache));
const merged = results.map(r => ({
...r,
gwoeScore: checkedIds.has(r.drucksache)
? allAssessments.find(a => a.drucksache === r.drucksache)?.gwoeScore
: null,
status: checkedIds.has(r.drucksache) ? 'checked' : 'unchecked'
}));
renderList(merged);
}
} catch (e) {
document.getElementById('list-content').innerHTML = `
< p style = "padding: 1rem; color: #888;" > Suchfehler: ${e.message}< / p >
`;
}
btn.disabled = false;
btn.textContent = '🔍 Im Landtag';
}
function filterList(query) {
const q = query.toLowerCase();
let filtered = allAssessments.filter(a =>
(a.title || '').toLowerCase().includes(q) ||
(a.drucksache || '').toLowerCase().includes(q) ||
(a.fraktionen || []).join(' ').toLowerCase().includes(q) ||
(a.themen || []).join(' ').toLowerCase().includes(q)
);
// Score-Filter anwenden
if (currentScoreFilter !== 'all') {
filtered = applyScoreFilter(filtered, currentScoreFilter);
}
// Partei-Filter anwenden
if (currentParteiFilter) {
filtered = filtered.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
2026-04-10 23:26:05 +02:00
renderList(sortAssessments(filtered));
2026-03-28 22:30:24 +01:00
}
function setScoreFilter(filter, btn) {
currentScoreFilter = filter;
document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active'));
btn.classList.add('active');
applyAllFilters();
}
function setParteiFilter(partei) {
currentParteiFilter = partei;
applyAllFilters();
}
function applyAllFilters() {
let filtered = allAssessments;
// Score-Filter
if (currentScoreFilter !== 'all') {
filtered = applyScoreFilter(filtered, currentScoreFilter);
}
// Partei-Filter
if (currentParteiFilter) {
filtered = filtered.filter(a =>
(a.fraktionen || []).includes(currentParteiFilter)
);
}
2026-04-10 23:26:05 +02:00
renderList(sortAssessments(filtered));
2026-03-28 22:30:24 +01:00
}
function applyScoreFilter(items, filter) {
switch (filter) {
case 'high': return items.filter(a => a.gwoeScore >= 8);
case 'mid': return items.filter(a => a.gwoeScore >= 5 & & a.gwoeScore < 8 ) ;
case 'low': return items.filter(a => a.gwoeScore < 5 ) ;
default: return items;
}
}
function showDetail(drucksache) {
const item = allAssessments.find(a => a.drucksache === drucksache);
if (!item) return;
// Highlight active item
document.querySelectorAll('.list-item').forEach(el => el.classList.remove('active'));
// Find and highlight the list item by drucksache
const listItem = document.querySelector(`.list-item[data-drucksache="${drucksache}"]`);
if (listItem) listItem.classList.add('active');
// Skala 0-10
const scoreClass = item.gwoeScore >= 8 ? 'score-high' :
item.gwoeScore >= 5 ? 'score-mid' :
item.gwoeScore >= 3 ? 'score-low' : 'score-negative';
// Matrix als 5x5-Tabelle wie im PDF
const matrixData = {};
(item.gwoeMatrix || []).forEach(m => { matrixData[m.field] = m; });
2026-04-10 23:06:37 +02:00
// GWÖ-Matrix Definitionen (aus models.py)
2026-03-28 22:30:24 +01:00
const rowLabels = {
2026-04-10 23:06:37 +02:00
'A': 'Ausgelagerte Betriebe, Lieferant:innen',
'B': 'Finanzpartner:innen, Steuerzahler:innen',
'C': 'Politische Führung, Verwaltung',
'D': 'Bürger:innen und Wirtschaft',
'E': 'Staat, Gesellschaft und Natur'
};
const rowTooltips = {
'A': 'Externe Beschaffung, Lieferketten, Dienstleister:innen',
'B': 'Umgang mit öffentlichen Mitteln, Haushalt, Geldgeber:innen',
'C': 'Mandatsträger:innen, Mitarbeitende, Ehrenamtliche',
'D': 'Wirkung innerhalb der Grenzen, Daseinsvorsorge',
'E': 'Wirkung über die Grenzen hinaus, Zukunft'
2026-03-28 22:30:24 +01:00
};
const colLabels = {
2026-04-10 23:06:37 +02:00
1: 'Menschenwürde',
2: 'Solidarität',
3: 'Ökol. Nachhaltigkeit',
4: 'Soz. Gerechtigkeit',
2026-03-28 22:30:24 +01:00
5: 'Transparenz'
};
const colFull = {
1: 'Menschenwürde',
2026-04-10 23:06:37 +02:00
2: 'Solidarität',
2026-03-28 22:30:24 +01:00
3: 'Ökologische Nachhaltigkeit',
4: 'Soziale Gerechtigkeit',
5: 'Transparenz & Mitbestimmung'
};
2026-04-10 23:06:37 +02:00
const colPrinzip = {
1: 'Rechtsstaatsprinzip — Werden Grundrechte geschützt? Rechtliche Gleichstellung?',
2: 'Gemeinnutz — Wird das Gemeinwohl gefördert? Mehrwert für die Gemeinschaft?',
3: 'Umwelt-Verantwortung — Klimaschutz? Ressourcenschonung? Biodiversität?',
4: 'Sozialstaatsprinzip — Gerechte Verteilung? Daseinsvorsorge? Soziale Absicherung?',
5: 'Demokratie — Bürgerbeteiligung? Offenlegung? Demokratische Prozesse?'
};
const fieldLabels = {{ matrix_labels | tojson }};
const ratingExplain = (r) => r >= 4 ? '++ stark fördernd' : r >= 1 ? '+ fördernd' : r === 0 ? '○ neutral' : r >= -3 ? '− widersprechend' : '− − stark widersprechend';
2026-04-10 23:38:37 +02:00
// Bürger:innen-Erklärungen pro Feld
const fieldExplain = {{ matrix_explanations | tojson }};
2026-04-10 23:06:37 +02:00
let matrixTableHtml = '< table class = "matrix-table" > < thead > < tr > < th style = "min-width:120px;" > < / th > ';
2026-04-10 23:43:57 +02:00
for (let col = 1; col < = 5; col++) matrixTableHtml += `< th style = "cursor:pointer;" onclick = "showColumnInfo(${col})" > ${col}. ${colLabels[col]}< / th > `;
2026-03-28 22:30:24 +01:00
matrixTableHtml += '< / tr > < / thead > < tbody > ';
2026-04-10 23:06:37 +02:00
2026-03-28 22:30:24 +01:00
['A', 'B', 'C', 'D', 'E'].forEach(row => {
2026-04-10 23:43:57 +02:00
matrixTableHtml += `< tr > < th style = "cursor:pointer;text-align:left;font-size:0.8rem;" onclick = "showRowInfo('${row}')" > ${row}: ${rowLabels[row]}< / th > `;
2026-03-28 22:30:24 +01:00
for (let col = 1; col < = 5; col++) {
const field = `${row}${col}`;
const entry = matrixData[field];
2026-04-10 23:06:37 +02:00
const fullLabel = fieldLabels[field] || field;
2026-03-28 22:30:24 +01:00
if (entry) {
const cssClass = entry.rating > 0 ? 'positive' : (entry.rating < 0 ? ' negative ' : ' neutral ' ) ;
2026-04-10 23:40:21 +02:00
// Aspect in data-Attribut speichern statt inline JS-String
matrixTableHtml += `< td class = "${cssClass}" style = "cursor:pointer;" data-field = "${field}" data-aspect = "${(entry.aspect||'').replace(/" / g , ' & quot ; ' ) } " data-rating = "${entry.rating}" onclick = "showFieldInfo(this.dataset.field, this.dataset.aspect, Number(this.dataset.rating))" > ${entry.symbol}< / td > `;
2026-03-28 22:30:24 +01:00
} else {
2026-04-10 23:38:37 +02:00
matrixTableHtml += `< td style = "cursor:pointer;color:#ccc;" onclick = "showFieldInfo('${field}', '', 0)" > ○< / td > `;
2026-03-28 22:30:24 +01:00
}
}
matrixTableHtml += '< / tr > ';
});
matrixTableHtml += '< / tbody > < / table > ';
2026-04-10 23:06:37 +02:00
// Detail-Liste der bewerteten Felder mit vollen Beschreibungen
2026-03-28 22:30:24 +01:00
const matrixDetailHtml = (item.gwoeMatrix || []).map(m => `
2026-04-10 23:06:37 +02:00
< div class = "matrix-item" title = "${ratingExplain(m.rating)}" >
< span class = "matrix-label" > < strong > ${m.field}< / strong > ${fieldLabels[m.field] || m.label}: < em > ${m.aspect || ''}< / em > < / span >
< span class = "matrix-rating ${m.rating > 0 ? 'rating-pos' : m.rating < 0 ? 'rating-neg' : 'rating-neutral'}" > ${m.symbol} (${m.rating})< / span >
2026-03-28 22:30:24 +01:00
< / div >
`).join('');
const stärkenHtml = (item.stärken || []).map(s => `< li > ${s}< / li > `).join('');
const schwächenHtml = (item.schwächen || []).map(s => `< li > ${s}< / li > `).join('');
// Verbesserungsvorschläge formatieren
const verbesserungenHtml = (item.verbesserungen || []).map(v => {
// Redline-Format: **fett** = grün/neu, ~~durchgestrichen~~ = rot/gelöscht
let vorschlag = v.vorschlag || '';
vorschlag = vorschlag.replace(/\*\*([^*]+)\*\*/g, '< span style = "color: #889e33; font-weight: bold;" > $1< / span > ');
vorschlag = vorschlag.replace(/~~([^~]+)~~/g, '< span style = "color: #d00000; text-decoration: line-through;" > $1< / span > ');
return `
< div style = "margin: 0.75rem 0; padding: 0.75rem; border: 1px solid var(--color-lightgray); border-radius: 4px;" >
< div style = "background: #f5f5f5; padding: 0.5rem; margin-bottom: 0.5rem; font-size: 0.9rem;" >
< strong > Original:< / strong > < br > ${v.original || '-'}
< / div >
< div style = "background: rgba(136, 158, 51, 0.1); border-left: 3px solid #889e33; padding: 0.5rem; font-size: 0.9rem;" >
< strong > Vorschlag:< / strong > < br > ${vorschlag}
< / div >
< div style = "font-size: 0.85rem; color: #666; margin-top: 0.5rem; font-style: italic;" >
${v.begruendung || ''}
< / div >
< / div >
`}).join('');
2026-04-10 09:57:58 +02:00
// Issue #47: Zitat-URLs zu Cite-Endpoint umschreiben für gelbes
// Highlighting. Funktioniert retroaktiv für Pre-#47-Assessments
// (statische /static/referenzen/X.pdf#page=N) und nativ für
// Post-#47 (die schon /api/wahlprogramm-cite enthalten).
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
// makeCiteUrl baut die Highlight-URL und hängt ds+bl an,
// damit der Server bei nicht-auffindbaren Zitaten automatisch
// eine Re-Analyse triggern kann (#47 + #60).
function makeCiteUrl(z, ds, bl) {
2026-04-10 09:57:58 +02:00
if (!z || !z.url) return '#';
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
const extra = (ds & & bl) ? `& ds=${encodeURIComponent(ds)}& bl=${encodeURIComponent(bl)}` : '';
// Schon eine Cite-URL? ds/bl anhängen + #page=N.
2026-04-10 10:16:00 +02:00
if (z.url.includes('/api/wahlprogramm-cite')) {
const m = z.url.match(/seite=(\d+)/);
const page = m ? m[1] : '';
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
const base = z.url.split('#')[0];
return base + extra + (page ? '#page=' + page : '');
2026-04-10 10:16:00 +02:00
}
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
// Statische URL umschreiben
2026-04-10 09:57:58 +02:00
const m = z.url.match(/\/static\/referenzen\/(.+\.pdf)#page=(\d+)/);
if (m & & z.text) {
const pdf = m[1];
const page = m[2];
const q = encodeURIComponent((z.text || '').substring(0, 200));
#47: Auto-Re-Analyse bei nicht-verifizierbaren Zitaten
Statt eine Nachricht "Textstelle nicht auffindbar" zu zeigen (was User
zurecht als Quatsch bezeichnet hat), erkennt der Cite-Endpoint jetzt
halluzinierte Zitate und triggert automatisch eine Re-Analyse:
Flow:
1. User klickt auf Zitat-Link
2. render_highlighted_page gibt (pdf, page, highlighted=False) zurück
3. Endpoint prüft: ds+bl Parameter vorhanden? Assessment in DB?
4. → Löscht altes Assessment, startet Re-Analyse als Background-Task
5. → Zeigt HTML-Warte-Seite mit Spinner und "Wird neu analysiert..."
6. → Auto-Redirect nach 15s zurück zum Assessment
Das neue Assessment hat durch reconstruct_zitate verifizierte Zitate,
die dann beim nächsten Klick korrekt gehighlighted werden.
Änderungen:
- embeddings.render_highlighted_page: Return-Typ (bytes, int, bool) —
drittes Element ist True wenn Highlight gesetzt wurde
- database.delete_assessment: neue Funktion für die Re-Analyse
- main.py cite-Endpoint: akzeptiert ds= und bl= als optionale Params,
triggert Re-Analyse bei highlighted=False + ds vorhanden
- Frontend: makeCiteUrl reicht ds+bl aus dem Assessment-Kontext mit
durch in die Cite-URL
- Cache-Control auf 1h reduziert (war 24h, zu aggressiv für
Assessments die sich durch Re-Analyse ändern)
Tests: 194/194 grün.
Refs: #47, #60
2026-04-10 10:35:01 +02:00
return `/api/wahlprogramm-cite?pdf=${encodeURIComponent(pdf)}&seite=${page}&q=${q}${extra}#page=${page}`;
2026-04-10 09:57:58 +02:00
}
return z.url;
}
2026-03-28 22:30:24 +01:00
const wahlprogrammHtml = (item.wahlprogrammScores || []).map(wp => {
2026-04-10 09:57:58 +02:00
// Zitate formatieren mit klickbaren Links + Highlighting
2026-04-10 21:45:36 +02:00
const zitateHtml = (wp.wahlprogramm?.zitate || []).map(z => {
const isVerified = z.verified !== false;
const borderColor = isVerified ? '#889e33' : '#ffc107';
const bgColor = isVerified ? '#f8f9fa' : '#fffbf0';
const badge = isVerified
? '< span style = "font-size:0.7rem;color:#889e33;" > ✓ verifiziert< / span > '
: '< span style = "font-size:0.7rem;color:#b8860b;" > ~ paraphrasiert (nicht wörtlich im Programm)< / span > ';
return `
< div style = "margin: 0.5rem 0; padding: 0.5rem; background: ${bgColor}; border-left: 3px ${isVerified ? 'solid' : 'dashed'} ${borderColor}; font-size: 0.85rem;" >
2026-03-28 22:30:24 +01:00
< em > "${z.text}"< / em > < br >
2026-04-10 21:45:36 +02:00
${z.quelle ? `< a href = "${makeCiteUrl(z, item.drucksache, item.bundesland)}" target = "_blank" style = "color: #009da5; font-size: 0.8rem;" >
2026-03-28 22:30:24 +01:00
📄 ${z.quelle}
2026-04-10 21:45:36 +02:00
< / a > ` : ''}
${badge}
< / div > `;
}).join('');
#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
2026-04-10 09:32:31 +02:00
// Issue #63: Transparenz-Warnung bei Score > 0 ohne Zitate.
// Differenziert zwischen "Score 0 = keine Quellen" (LLM hat
// Force-Honesty befolgt) und "Score > 0 aber 0 Zitate" (LLM
// hat trotzdem geratet oder Zitate wurden von reconstruct_zitate
// verworfen). Nur bei Scores > 0 warnen, weil Score 0 schon
// selbsterklärend ist.
const wpScore = wp.wahlprogramm?.score ?? 0;
const wpZitateCount = (wp.wahlprogramm?.zitate || []).length;
const noQuotesWarning = (wpScore > 0 & & wpZitateCount === 0) ? `
< div style = "margin: 0.5rem 0; padding: 0.5rem; background: #fff3cd; border-left: 3px solid #ffc107; font-size: 0.8rem; color: #856404;" >
2026-04-10 21:41:15 +02:00
⚠ Zu diesem Themenkomplex konnten keine konkreten Formulierungen im Wahlprogramm gefunden werden — Score basiert auf der allgemeinen Programmatik der Partei.
#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
2026-04-10 09:32:31 +02:00
< / div >
` : '';
2026-04-10 22:13:30 +02:00
// Labels: Antragsteller:in (aus item.fraktionen) und Landesregierung
// istAntragsteller/istRegierung aus dem LLM ist oft null — ableiten.
const isAntragsteller = (item.fraktionen || []).some(f => normalizePartei(f) === normalizePartei(wp.fraktion));
2026-04-10 21:41:15 +02:00
const roleLabels = [];
2026-04-10 22:13:30 +02:00
if (wp.istAntragsteller || isAntragsteller) roleLabels.push('< span style = "background:#889e33;color:white;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;" > Antragsteller:in< / span > ');
2026-04-10 21:41:15 +02:00
if (wp.istRegierung) roleLabels.push('< span style = "background:#009da5;color:white;padding:0.1rem 0.4rem;border-radius:3px;font-size:0.75rem;" > Landesregierung< / span > ');
2026-03-28 22:30:24 +01:00
return `
< div style = "margin: 0.75rem 0; padding: 0.75rem; background: var(--color-bg); border-radius: 4px;" >
2026-04-10 21:41:15 +02:00
< strong > ${wp.fraktion}< / strong > ${roleLabels.join(' ')}< br >
2026-03-28 22:30:24 +01:00
< div style = "margin: 0.5rem 0;" >
#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
2026-04-10 09:32:31 +02:00
Wahlprogramm: < strong > ${wp.wahlprogramm?.score || '-'}/10< / strong > ·
2026-03-28 22:30:24 +01:00
Parteiprogramm: < strong > ${wp.parteiprogramm?.score || '-'}/10< / strong >
< / div >
${wp.wahlprogramm?.begründung ? `< div style = "font-size: 0.9rem; color: #555; margin-bottom: 0.5rem;" > ${wp.wahlprogramm.begründung}< / div > ` : ''}
#63 B+C: Force-Honesty + UI-Warning bei Score ohne Zitate
Problem: BUND 21/3660 zeigt Score 10/10 für Linke und Grüne, aber null
Zitate — der Report sieht aus als sei die Bewertung fundiert, obwohl das
LLM mangels indexierter Quellen (linke-grundsatz fehlt) aus
Trainingswissen geraten hat. User-Feedback: "Da muss stehen warum."
Fix C — Force-Honesty im Prompt:
- format_quotes_for_prompt akzeptiert neuen Parameter searched_parties.
Parteien, für die kein Chunk retrievt wurde, werden explizit als
"KEINE QUELLEN VORHANDEN" markiert, mit der Anweisung "score: 0,
zitate: [], Begründung: keine Quellen im Index".
- Neue ZITATEREGEL Punkt 5: "Wenn KEINE QUELLEN VORHANDEN → score 0."
Das ist die strukturelle Lösung — das LLM darf nicht mehr raten.
- analyzer.py: fraktionen-Liste wird an format_quotes_for_prompt als
searched_parties durchgereicht.
Fix B — UI-Transparenz:
- index.html: gelbe Warn-Box (amber, border-left #ffc107) wenn
wp.wahlprogramm.score > 0 aber wp.wahlprogramm.zitate.length === 0:
"Keine belegbaren Quellen im Index gefunden — Score basiert auf
LLM-Einschätzung, nicht auf verifizierten Programm-Stellen."
- Wird für bestehende Assessments sofort sichtbar (JS-seitig berechnet),
keine DB-Migration nötig. Neue Assessments nach Force-Honesty sollten
idealerweise Score=0 haben, aber die Warning ist ein Fallback für
den Fall dass das LLM die Prompt-Regel nicht immer 100% befolgt.
Fix A (Linke/AfD-Grundsatzprogramme) folgt als separater Commit —
sind öffentlich downloadbar, brauchen manuellen Sichtbarkeitscheck.
Tests: 194/194 grün (keine Schema-Änderung, nur Prompt + Template).
Refs: #63, ADR 0001
2026-04-10 09:32:31 +02:00
${noQuotesWarning}
2026-03-28 22:30:24 +01:00
${zitateHtml}
< / div >
`}).join('');
document.getElementById('detail-panel').innerHTML = `
< div class = "detail-card" >
2026-04-07 13:48:55 +02:00
< button class = "btn-back-mobile" onclick = "document.querySelector('.list-panel').scrollIntoView({behavior:'smooth', block:'start'})" > ← Zur Liste< / button >
2026-03-28 22:30:24 +01:00
< div class = "detail-header" >
< div >
< div class = "detail-title" > ${item.title || 'Ohne Titel'}< / div >
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
${item.bundesland & & PARLAMENT_NAMES[item.bundesland] ? `< div class = "detail-parlament" > ${PARLAMENT_NAMES[item.bundesland]}< / div > ` : ''}
2026-04-10 22:13:30 +02:00
< div class = "detail-id" > ${item.drucksache} · ${(item.fraktionen || []).join(', ')} · ${item.datum || ''}${item.updatedAt ? ` · Bewertet ${new Date(item.updatedAt).toLocaleDateString('de-DE')}` : ''}< / div >
2026-03-28 22:30:24 +01:00
< / div >
< div class = "score-display" >
< div class = "score-big ${scoreClass}" > ${item.gwoeScore}< / div >
< div class = "score-label" > GWÖ-Score< / div >
< / div >
< / div >
${item.themen & & item.themen.length > 0 ? `
< div class = "themen-tags" style = "margin-bottom: 1rem;" >
${item.themen.map(t => `< span class = "tag" > ${t}< / span > `).join(' ')}
< / div >
` : ''}
< h3 class = "section-title" > Zusammenfassung< / h3 >
< div class = "text-block" > ${item.antragZusammenfassung || '-'}< / div >
${item.antragKernpunkte & & item.antragKernpunkte.length > 0 ? `
< h3 class = "section-title" > Kernpunkte< / h3 >
< ul class = "kernpunkte-list" > ${item.antragKernpunkte.map(k => `< li > ${k}< / li > `).join('')}< / ul >
` : ''}
< h3 class = "section-title" > GWÖ-Begründung< / h3 >
< div class = "text-block" > ${item.gwoeBegründung || '-'}< / div >
${item.gwoeSchwerpunkt ? `
< h3 class = "section-title" > GWÖ-Schwerpunkt< / h3 >
< div class = "text-block" > ${item.gwoeSchwerpunkt}< / div >
` : ''}
${item.gwoeMatrix & & item.gwoeMatrix.length > 0 ? `
< h3 class = "section-title" > GWÖ-Matrix< / h3 >
${matrixTableHtml}
< div class = "matrix-grid" style = "margin-top: 0.75rem;" > ${matrixDetailHtml}< / div >
` : ''}
${wahlprogrammHtml ? `
< h3 class = "section-title" > Programmtreue< / h3 >
${wahlprogrammHtml}
` : ''}
${stärkenHtml ? `
< h3 class = "section-title" > Stärken< / h3 >
< ul class = "strength-list" > ${stärkenHtml}< / ul >
` : ''}
${schwächenHtml ? `
< h3 class = "section-title" > Schwächen< / h3 >
< ul class = "weakness-list" > ${schwächenHtml}< / ul >
` : ''}
${verbesserungenHtml ? `
< h3 class = "section-title" > Verbesserungsvorschläge< / h3 >
${verbesserungenHtml}
` : ''}
${item.verbesserungspotenzial ? `
< h3 class = "section-title" > Verbesserungspotenzial< / h3 >
< div class = "text-block" > ${item.verbesserungspotenzial}< / div >
` : ''}
< h3 class = "section-title" > Empfehlung< / h3 >
< div class = "text-block" >
< strong > ${item.empfehlungSymbol || ''} ${item.empfehlung || '-'}< / strong >
< / div >
2026-04-10 21:10:33 +02:00
< div style = "display: flex; gap: 0.5rem; flex-wrap: wrap; margin-top: 1rem;" >
< a href = "${item.link}" target = "_blank" class = "btn-pdf" > 📄 Original-PDF< / a >
< a href = "/api/assessment/pdf?drucksache=${encodeURIComponent(item.drucksache)}" class = "btn-pdf" style = "background: var(--color-green);" > 📥 GWÖ-Report< / a >
< button class = "btn-pdf" style = "background: #6c757d; border: none; cursor: pointer;"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung verfügbar" style="background:#6c757d;opacity:0.5;cursor:not-allowed;"'}
onclick="reAnalyze('${item.drucksache}', '${item.bundesland}', this)">
🔄 Neu bewerten
< / button >
< / div >
< div style = "margin-top: 0.75rem; font-size: 0.8rem; color: #999; border-top: 1px solid #eee; padding-top: 0.5rem;" >
2026-04-10 21:24:07 +02:00
Bewertet am ${item.updatedAt ? new Date(item.updatedAt).toLocaleDateString('de-DE') + ', ' + new Date(item.updatedAt).toLocaleTimeString('de-DE', {hour:'2-digit', minute:'2-digit'}) + ' Uhr' : '– '}
2026-04-10 21:10:33 +02:00
${item.source ? ` · Quelle: ${item.source}` : ''}
${item.model ? ` · Modell: ${item.model}` : ''}
< / div >
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
<!-- Bookmark + Kommentare (#94) -->
< div style = "margin-top: 1rem; border-top: 2px solid var(--color-lightgray); padding-top: 1rem;" >
< div style = "display: flex; align-items: center; gap: 1rem; margin-bottom: 0.75rem;" >
< button id = "bookmark-btn-${item.drucksache.replace('/','-')}"
style="background:none;border:1px solid #ddd;border-radius:4px;padding:0.3rem 0.8rem;cursor:pointer;font-size:0.9rem;"
onclick="toggleBookmark('${item.drucksache}', this)"
${currentUser ? '' : 'disabled title="Nur nach Anmeldung"'}>
🔖 Merken
< / button >
< span style = "font-size: 0.85rem; color: #888;" > Kommentare:< / span >
< / div >
< div id = "comments-${item.drucksache.replace('/','-')}" style = "margin-bottom: 0.75rem;" >
< span style = "color:#aaa;font-size:0.85rem;" > Lade Kommentare...< / span >
< / div >
${currentUser ? `
2026-04-10 22:40:27 +02:00
< div style = "display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;" >
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
< input id = "comment-input-${item.drucksache.replace('/','-')}" type = "text"
placeholder="Kommentar schreiben..."
2026-04-10 22:40:27 +02:00
style="flex:1;min-width:150px;padding:0.4rem;border:1px solid #ddd;border-radius:4px;font-size:0.85rem;"
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
onkeydown="if(event.key==='Enter')addCommentUI('${item.drucksache}')">
2026-04-10 22:40:27 +02:00
< select id = "comment-visibility-${item.drucksache.replace('/','-')}"
style="padding:0.4rem;border:1px solid #ddd;border-radius:4px;font-size:0.8rem;color:#666;">
< option value = "all" > 🌐 Öffentlich< / option >
< option value = "authenticated" > 🔒 Angemeldete< / option >
< option value = "private" > 👤 Nur ich< / option >
< / select >
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
< button onclick = "addCommentUI('${item.drucksache}')"
style="padding:0.4rem 0.8rem;background:var(--color-blue);color:white;border:none;border-radius:4px;cursor:pointer;font-size:0.85rem;">
Senden
< / button >
< / div >
` : '< span style = "font-size:0.8rem;color:#aaa;" > Anmelden um zu kommentieren< / span > '}
< / div >
2026-03-28 22:30:24 +01:00
< / div >
`;
2026-04-07 13:48:55 +02:00
#94 Bookmarks + Kommentare: DB-Schema, API, UI
DB (database.py):
- bookmarks-Tabelle (user_id + drucksache, toggle)
- comments-Tabelle (user_id, user_name, drucksache, text, visibility)
- Functions: toggle_bookmark, get_bookmarks, add_comment, get_comments, delete_comment
API (main.py):
- POST /api/bookmark (toggle, Auth-pflichtig)
- GET /api/bookmarks (User-Bookmarks)
- POST /api/comment (Auth-pflichtig, max 2000 Zeichen)
- GET /api/comments?drucksache= (öffentlich)
- DELETE /api/comment/{id} (nur eigene, Auth-pflichtig)
UI (index.html):
- Bookmark-Button ("🔖 Merken" / "⭐ Gemerkt") im Detail-Footer
- Kommentar-Bereich: Liste + Eingabefeld + Senden-Button
- Kommentare laden automatisch beim Detail-Öffnen
- Eigene Kommentare löschbar (✕ Button)
- Ohne Login: "Anmelden um zu kommentieren"
Gruppen-Sichtbarkeit (visibility) ist vorbereitet aber noch nicht
im UI exponiert — kommt als separater Schritt wenn Keycloak-Gruppen
konfiguriert sind.
Tests: 206 passed.
Refs: #94
2026-04-10 22:19:46 +02:00
// Kommentare + Bookmark-Status laden
loadComments('${item.drucksache}');
loadBookmarkState('${item.drucksache}');
2026-04-07 13:48:55 +02:00
// Auf Mobile: zum Detail-Panel scrollen, damit der gewählte Antrag sichtbar wird
if (window.matchMedia('(max-width: 900px)').matches) {
document.getElementById('detail-panel').scrollIntoView({ behavior: 'smooth', block: 'start' });
}
2026-03-28 22:30:24 +01:00
}
// Mode Toggle
function showMode(mode) {
document.querySelectorAll('.mode-btn').forEach(b => b.classList.remove('active'));
if (event & & event.currentTarget) {
event.currentTarget.classList.add('active');
} else {
// Find and activate the right button
document.querySelectorAll('.mode-btn').forEach(b => {
if (b.textContent.includes(mode === 'browse' ? 'Durchsuchen' : mode === 'tags' ? 'Tags' : 'Prüfen')) {
b.classList.add('active');
}
});
}
document.getElementById('browse-mode').style.display = mode === 'browse' ? 'flex' : 'none';
2026-04-10 22:24:43 +02:00
document.getElementById('bookmarks-mode').style.display = mode === 'bookmarks' ? 'flex' : 'none';
2026-03-28 22:30:24 +01:00
document.getElementById('tags-mode').style.display = mode === 'tags' ? 'flex' : 'none';
document.getElementById('upload-mode').style.display = mode === 'upload' ? 'flex' : 'none';
2026-04-10 22:24:43 +02:00
if (mode === 'bookmarks') loadBookmarksList();
2026-03-28 22:30:24 +01:00
}
// Upload Tab Toggle
function showTab(tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
event.currentTarget.classList.add('active');
document.getElementById('tab-text').style.display = tab === 'text' ? 'block' : 'none';
document.getElementById('tab-file').style.display = tab === 'file' ? 'block' : 'none';
}
function handleFile(input) {
if (input.files[0]) {
document.getElementById('file-name').textContent = input.files[0].name;
}
}
async function startAnalysis() {
const btn = document.getElementById('analyze-btn');
const statusDiv = document.getElementById('analysis-status');
const resultDiv = document.getElementById('analysis-result');
const text = document.getElementById('antrag-text').value;
const file = document.getElementById('file-input').files[0];
const bundesland = document.getElementById('bundesland').value;
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
2026-03-28 22:30:24 +01:00
if (!text & & !file) {
alert('Bitte Text eingeben oder PDF hochladen');
return;
}
Bundesland filter & transparency: stringent split + visible source (#8)
Brings the Bundesland-Dropdown from a cosmetic header widget to a real
filter that propagates through every layer (Listing, internal search,
statistics, party/tag filters, upload mode), and at the same time makes
the source parliament visible in every place where assessments from
multiple bundesländer can be mixed.
Backend
- database.get_all_assessments(bundesland=None) — new optional filter,
"ALL" treated as None.
- database.search_assessments — bug fix: previous `if bundesland:`
branch incorrectly added a `WHERE bundesland='ALL'` clause; now
guarded with `bundesland and bundesland != "ALL"`.
- main.list_assessments — accepts ?bundesland= query param, includes the
bundesland field in the response so the frontend can render badges.
- main.get_single_assessment — also includes bundesland in the response
so the detail header can show the source parlament.
- main.search_landtag — early HTTP 400 when bundesland is missing or
"ALL"; the live Landtag adapter cannot serve a synthetic Bundesweit
request.
- main.index() and main.list_bundeslaender — synthetic "🌍 Bundesweit"
entry prepended to the bundesländer list (kept out of bundeslaender.py
on purpose — ALL is not a real state). Both endpoints additionally
expose a parlament_names map so the frontend can render the source
parliament without an extra round-trip.
Report (PDF + HTML)
- generate_html_report / generate_pdf_report — new optional bundesland
parameter. When set, the report header carries the parliament name
("Landtag von Sachsen-Anhalt", "Landtag Nordrhein-Westfalen", …)
beside the title. Three call sites updated: run_analysis,
run_drucksache_analysis, download_assessment_pdf.
Frontend (templates/index.html)
- Header dropdown gets the synthetic ALL entry as first option;
initial currentBundesland is now 'ALL' (was 'NRW').
- localStorage persistence: changeBundesland writes, DOMContentLoaded
reads and validates against the visible options.
- changeBundesland resets the score / party / tag filter state, syncs
the upload-mode bundesland select, disables the Landtag-Suche button
+ tooltip when ALL, and toggles a data-mode attribute on
.list-content (used by CSS to show/hide the per-item bundesland
badge).
- loadAssessments now sends ?bundesland=… so the API does the actual
filtering. updateStats renders an additional per-bundesland average
block (Ø NRW: x · Ø LSA: y) when in ALL mode and the loaded list
spans more than one bundesland.
- renderList prepends a small "bl-badge" beside the Drucksachen-Nummer.
Hidden in single-bundesland mode via CSS selector to avoid clutter.
- showDetail header now shows the parliament name as its own line
(.detail-parlament).
- searchLandtag has an early-out alert if currentBundesland === 'ALL',
saving a network round-trip.
- Upload-Mode bundesland select now starts with a "— Bundesland wählen
—" placeholder (no auto-default), and startAnalysis validates that a
concrete bundesland was chosen.
CSS
- .bl-badge plus the .list-content[data-mode="single"] hide rule.
- .detail-parlament for the detail header line.
- .header-parlament for the PDF report header line.
Resolves #8.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 23:00:39 +02:00
if (!bundesland) {
alert('Bitte ein Bundesland wählen.');
return;
}
2026-03-28 22:30:24 +01:00
btn.disabled = true;
statusDiv.style.display = 'block';
resultDiv.style.display = 'none';
try {
const formData = new FormData();
if (text) formData.append('text', text);
if (file) formData.append('file', file);
formData.append('bundesland', bundesland);
formData.append('model', 'qwen-plus');
const resp = await fetch('/analyze', { method: 'POST', body: formData });
const data = await resp.json();
if (data.job_id) {
pollStatus(data.job_id);
} else {
throw new Error(data.detail || 'Fehler');
}
} catch (e) {
statusDiv.innerHTML = `< p style = "color: #dc3545;" > ✗ Fehler: ${e.message}< / p > `;
btn.disabled = false;
}
}
async function pollStatus(jobId) {
const statusText = document.getElementById('status-text');
const statusDiv = document.getElementById('analysis-status');
const resultDiv = document.getElementById('analysis-result');
const btn = document.getElementById('analyze-btn');
try {
const resp = await fetch(`/status/${jobId}`);
const data = await resp.json();
if (data.status === 'completed') {
statusDiv.style.display = 'none';
resultDiv.innerHTML = `
< p style = "color: var(--color-green);" > ✓ Analyse abgeschlossen!< / p >
< a href = "/result/${jobId}" class = "btn-pdf" style = "background: var(--color-green);" > 📊 Ergebnis ansehen< / a >
< a href = "/result/${jobId}/pdf" class = "btn-pdf" > 📄 PDF herunterladen< / a >
`;
resultDiv.style.display = 'block';
btn.disabled = false;
} else if (data.status === 'failed') {
statusDiv.innerHTML = `< p style = "color: #dc3545;" > ✗ Fehler: Analyse fehlgeschlagen. Bitte erneut versuchen.< / p > `;
btn.disabled = false;
} else {
statusText.textContent = `Analysiere... (${data.status})`;
setTimeout(() => pollStatus(jobId), 2000);
}
} catch (e) {
statusDiv.innerHTML = `< p style = "color: #dc3545;" > ✗ Fehler: ${e.message}< / p > `;
btn.disabled = false;
}
}
< / script >
2026-04-10 23:27:27 +02:00
<!-- Queue - Panel Overlay -->
< div id = "queue-panel" style = "display:none;position:fixed;top:0;right:0;width:400px;height:100vh;background:white;box-shadow:-4px 0 12px rgba(0,0,0,0.15);z-index:200;overflow-y:auto;padding:1.5rem;" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;" >
< h3 style = "color:var(--color-blue);margin:0;" > 📊 Queue< / h3 >
< button onclick = "document.getElementById('queue-panel').style.display='none'" style = "background:none;border:none;font-size:1.2rem;cursor:pointer;" > ✕< / button >
< / div >
< div id = "queue-panel-content" > < span style = "color:#aaa;" > Lade...< / span > < / div >
< / div >
<!-- Batch - Panel Overlay -->
2026-04-10 23:53:05 +02:00
<!-- Registrierungs - Panel -->
< div id = "register-panel" style = "display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:400px;background:white;box-shadow:0 8px 24px rgba(0,0,0,0.2);z-index:200;border-radius:8px;padding:1.5rem;" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;" >
< h3 style = "color:var(--color-blue);margin:0;" > Registrieren< / h3 >
< button onclick = "document.getElementById('register-panel').style.display='none'" style = "background:none;border:none;font-size:1.2rem;cursor:pointer;" > ✕< / button >
< / div >
< form onsubmit = "submitRegistration(event)" style = "display:flex;flex-direction:column;gap:0.5rem;" >
< input name = "firstName" placeholder = "Vorname" required style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
< input name = "lastName" placeholder = "Nachname" required style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
< input name = "email" type = "email" placeholder = "E-Mail-Adresse" required style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
< input name = "username" placeholder = "Benutzername" required style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
< input name = "password" type = "password" placeholder = "Passwort (min. 8 Zeichen)" required minlength = "8" style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
< button type = "submit" style = "padding:0.5rem;background:var(--color-blue);color:white;border:none;border-radius:4px;cursor:pointer;" > Registrieren< / button >
< / form >
< div id = "register-status" style = "margin-top:0.5rem;font-size:0.85rem;" > < / div >
< p style = "font-size:0.8rem;color:#888;margin-top:0.5rem;" > Nach der Registrierung muss ein Administrator Ihren Account freischalten.< / p >
< / div >
2026-04-10 23:27:27 +02:00
< div id = "batch-panel" style = "display:none;position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);width:450px;background:white;box-shadow:0 8px 24px rgba(0,0,0,0.2);z-index:200;border-radius:8px;padding:1.5rem;" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;" >
< h3 style = "color:var(--color-blue);margin:0;" > 📦 Batch-Analyse< / h3 >
< button onclick = "document.getElementById('batch-panel').style.display='none'" style = "background:none;border:none;font-size:1.2rem;cursor:pointer;" > ✕< / button >
< / div >
< p style = "font-size:0.85rem;color:#666;margin-bottom:1rem;" > Analysiert automatisch die neuesten ungeprüften Anträge.< / p >
< div style = "display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;" >
< select id = "batch-bl-modal" style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
{% for bl in bundeslaender if bl.code != 'ALL' and bl.active %}
< option value = "{{ bl.code }}" > {{ bl.name }}< / option >
{% endfor %}
< / select >
< select id = "batch-limit-modal" style = "padding:0.4rem;border:1px solid #ddd;border-radius:4px;" >
< option value = "5" > 5< / option >
< option value = "10" selected > 10< / option >
< option value = "20" > 20< / option >
< option value = "50" > 50< / option >
< / select >
< button onclick = "startBatchModal()" style = "padding:0.4rem 1rem;background:var(--color-green);color:white;border:none;border-radius:4px;cursor:pointer;" > 🚀 Starten< / button >
< / div >
< div id = "batch-modal-status" style = "margin-top:0.75rem;font-size:0.85rem;" > < / div >
< / div >
< script >
2026-04-10 23:53:05 +02:00
async function showPendingUsers() {
try {
const users = await fetch('/api/auth/pending-users').then(r => r.json());
const html = users.length === 0
? '< p style = "color:#888;" > Keine ausstehenden Registrierungen.< / p > '
: users.map(u => `
< div style = "padding:0.5rem;border-bottom:1px solid #eee;display:flex;justify-content:space-between;align-items:center;" >
< div > < strong > ${u.firstName} ${u.lastName}< / strong > < br > < span style = "color:#888;font-size:0.8rem;" > ${u.email} (${u.username})< / span > < / div >
< button onclick = "approveUser('${u.id}',this)" style = "padding:0.3rem 0.8rem;background:var(--color-green);color:white;border:none;border-radius:4px;cursor:pointer;" > ✓ Freischalten< / button >
< / div >
`).join('');
document.body.insertAdjacentHTML('beforeend', `
< div style = "position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:300;display:flex;justify-content:center;align-items:center;" onclick = "if(event.target===this)this.remove()" >
< div style = "background:white;border-radius:8px;padding:1.5rem;max-width:500px;width:90%;box-shadow:0 8px 24px rgba(0,0,0,0.2);max-height:80vh;overflow-y:auto;" >
< div style = "display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;" >
< h3 style = "color:var(--color-blue);margin:0;" > 👥 Ausstehende Registrierungen< / h3 >
< button onclick = "this.closest('[style*=fixed]').remove()" style = "background:none;border:none;font-size:1.2rem;cursor:pointer;" > ✕< / button >
< / div >
< div id = "pending-users-list" > ${html}< / div >
< / div >
< / div > `);
} catch (e) { alert('Fehler: ' + e.message); }
}
async function approveUser(userId, btn) {
btn.disabled = true; btn.textContent = '⏳...';
const resp = await fetch('/api/auth/approve-user', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'user_id=' + userId
});
if (resp.ok) { btn.textContent = '✓'; btn.style.background = '#888'; }
else { btn.textContent = '❌'; btn.disabled = false; }
}
async function submitRegistration(e) {
e.preventDefault();
const form = e.target;
const status = document.getElementById('register-status');
status.innerHTML = '< span style = "color:var(--color-blue);" > ⏳ Wird registriert...< / span > ';
try {
const resp = await fetch('/api/auth/register', {
method: 'POST',
headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: new URLSearchParams(new FormData(form)).toString()
});
const data = await resp.json();
if (resp.ok) {
status.innerHTML = '< span style = "color:var(--color-green);" > ✓ ' + data.message + '< / span > ';
form.reset();
} else {
status.innerHTML = '< span style = "color:#dc3545;" > ❌ ' + (data.detail || 'Fehler') + '< / span > ';
}
} catch (err) {
status.innerHTML = '< span style = "color:#dc3545;" > ❌ ' + err.message + '< / span > ';
}
}
2026-04-10 23:27:27 +02:00
function loadQueuePanel() {
const el = document.getElementById('queue-panel-content');
async function refresh() {
try {
const qs = await fetch('/api/queue/status').then(r => r.json());
const jobs = qs.jobs || [];
if (jobs.length === 0 & & qs.pending === 0) {
el.innerHTML = '< p style = "color:#888;" > Keine Aufträge in der Warteschlange.< / p > ';
return;
}
const completed = jobs.filter(j => j.status === 'completed').length;
const pct = jobs.length > 0 ? Math.round(completed / jobs.length * 100) : 0;
el.innerHTML = `
< div style = "margin-bottom:0.5rem;font-size:0.85rem;color:#666;" >
${qs.concurrency} Worker · ${qs.pending} wartend · ${qs.processed_total} verarbeitet
${qs.shutting_down ? '< br > < span style = "color:#dc3545;" > ⚠ Server wird heruntergefahren< / span > ' : ''}
< / div >
< div style = "background:#eee;border-radius:4px;height:6px;margin-bottom:0.75rem;" >
< div style = "background:var(--color-green);height:100%;width:${pct}%;transition:width 0.5s;border-radius:4px;" > < / div >
< / div >
< table style = "width:100%;font-size:0.8rem;border-collapse:collapse;" >
${jobs.map(j => {
const icon = j.status === 'completed' ? '✅' : j.status === 'processing' ? '⏳' : j.status === 'failed' ? '❌' : '⏸';
return '< tr style = "border-top:1px solid #f0f0f0;" > < td > ' + (j.drucksache || j.job_id.substring(0,8)) + '< / td > < td > ' + icon + '< / td > < td > ' + (j.duration ? j.duration + 's' : '') + '< / td > < / tr > ';
}).join('')}
< / table > `;
} catch { el.innerHTML = '< span style = "color:#c00;" > Fehler< / span > '; }
}
refresh();
// Auto-refresh solange Panel offen
const iv = setInterval(() => {
if (document.getElementById('queue-panel').style.display === 'none') { clearInterval(iv); return; }
refresh();
}, 3000);
}
async function startBatchModal() {
const bl = document.getElementById('batch-bl-modal').value;
const limit = document.getElementById('batch-limit-modal').value;
const status = document.getElementById('batch-modal-status');
status.innerHTML = '< span style = "color:var(--color-blue);" > ⏳ Wird gestartet...< / span > ';
try {
const resp = await fetch('/api/batch-analyze', {
method: 'POST', headers: {'Content-Type': 'application/x-www-form-urlencoded'},
body: 'bundesland=' + bl + '& limit=' + limit
});
if (resp.status === 401 || resp.status === 403) {
status.innerHTML = '< span style = "color:#dc3545;" > 🔒 Admin-Berechtigung erforderlich.< / span > ';
return;
}
const data = await resp.json();
status.innerHTML = '< span style = "color:var(--color-green);" > ✓ ' + (data.enqueued || 0) + ' Anträge eingereiht. Queue-Panel öffnen um Fortschritt zu sehen.< / span > ';
} catch (e) {
status.innerHTML = '< span style = "color:#dc3545;" > ❌ ' + e.message + '< / span > ';
}
}
< / script >
2026-03-28 22:30:24 +01:00
< / body >
< / html >