gwoe-antragspruefer/app/templates/auswertungen.html
Dotty Dotter 565849bd84 feat(#139,#129,#138,#141): v2-Frontend (ECOnGOOD-CD), Login-Modal, Auto-DL, OG-Cards
v2-Frontend (#139, ECOnGOOD CD Manual Juni 2024):
- app/static/v2/: tokens.css, fonts.css, v2.css, Nunito-Sans woff2, Phosphor-Icons (21 SVGs)
- app/templates/v2/: base.html + 11 Screens + 8 Component-Macros
- AppShell mit Sidebar (Lesen/Pruefen/Daten/Admin), v2-Detail mit allen Features
  (ScoreHero, MatrixMini, QuoteCard, Redline, Fraktions-Scores)
- v2 ist jetzt Default unter / — classic unter /classic
- Login-Modal in v2-Topbar mit Tabs Anmelden/Registrieren (#129)
- Phosphor-Icons in Sidebar + Topbar mit dynamischem Theme-Toggle
- Keyboard-Shortcuts (j/k/Enter/Esc/?/path), Landtag-Suche, Antrag-Historie,
  Sort-Dropdown, Matrix-Feld-Info-Modal, Bookmarks/Comments/Voting/Share/Re-Analyze

Backend-Erweiterungen:
- main.py: ~30 neue Routes (/v2/*, /antrag/{ds}, /api/auth/{login,refresh,logout},
  /api/me/merkliste/*, /api/admin/*, /v2/admin/*, OG-Cards, etc.)
- og_card.py + og_template: Open-Graph-Bilder via Playwright (#141)
- wahlprogramm_fetch.py + wahlprogramm-links.yaml: SHA-Gate Auto-DL (#138)
- auswertungen.py: BL-Filter + get_wahlperioden Helper (#137)
- auth.py: Direct-Access-Grant + Refresh-Token-Cookie

Classic-Updates:
- Header-DRY via _header.html, Auswertungen redirected, Batch-Inline raus

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 20:55:57 +02:00

375 lines
15 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Auswertungen — {{ app_name }}</title>
<style>
:root {
--color-darkgray: #5a5a5a;
--color-green: #889e33;
--color-blue: #007a80;
--color-lightgray: #bfbfbf;
--color-bg: #f5f5f5;
--color-orange: #F7941D;
--color-red: #d00000;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: 'Avenir', 'Segoe UI', sans-serif;
color: var(--color-darkgray);
line-height: 1.6;
background: var(--color-bg);
}
.header {
background: white;
padding: 1rem 2rem;
border-bottom: 1px solid var(--color-lightgray);
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 1rem;
}
.header h1 { color: var(--color-blue); font-size: 1.3rem; }
.header nav a {
color: var(--color-blue);
text-decoration: none;
margin-right: 1rem;
font-size: 0.9rem;
}
.header nav a:hover { text-decoration: underline; }
main { max-width: 1400px; margin: 1.5rem auto; padding: 0 2rem; }
.controls {
background: white;
padding: 1rem;
border-radius: 4px;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.controls label { font-size: 0.9rem; }
.controls select, .controls button {
padding: 0.4rem 0.7rem;
border: 1px solid var(--color-lightgray);
border-radius: 3px;
font-size: 0.9rem;
background: white;
color: var(--color-darkgray);
cursor: pointer;
}
.controls button.export {
background: var(--color-blue);
color: white;
border-color: var(--color-blue);
}
.matrix-wrap { background: white; padding: 1rem; border-radius: 4px; overflow-x: auto; }
table.matrix {
border-collapse: collapse;
width: 100%;
font-size: 0.85rem;
}
table.matrix th, table.matrix td {
border: 1px solid var(--color-lightgray);
padding: 0.4rem 0.6rem;
text-align: center;
}
table.matrix th {
background: var(--color-bg);
font-weight: 600;
color: var(--color-darkgray);
}
table.matrix th.row-header {
background: var(--color-blue);
color: white;
text-align: left;
position: sticky;
left: 0;
}
table.matrix .empty { color: var(--color-lightgray); }
table.matrix .score-high { background: rgba(136, 158, 51, 0.25); font-weight: 600; }
table.matrix .score-mid { background: rgba(247, 148, 29, 0.18); }
table.matrix .score-low { background: rgba(208, 0, 0, 0.18); font-weight: 600; }
.meta {
font-size: 0.8rem;
color: var(--color-lightgray);
margin-top: 0.6rem;
}
.empty-state {
background: white;
padding: 2rem;
text-align: center;
border-radius: 4px;
color: var(--color-lightgray);
}
table.matrix td.cell-with-data {
cursor: pointer;
}
table.matrix td.cell-with-data:hover {
outline: 2px solid var(--color-blue);
outline-offset: -2px;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.45);
display: none;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal-backdrop.show { display: flex; }
.modal {
background: white;
border-radius: 6px;
padding: 1.5rem 2rem;
min-width: 320px;
max-width: 90vw;
max-height: 80vh;
overflow-y: auto;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
}
.modal h2 {
color: var(--color-blue);
margin-bottom: 0.8rem;
font-size: 1.1rem;
}
.modal table {
width: 100%;
border-collapse: collapse;
margin-top: 0.5rem;
}
.modal table th, .modal table td {
padding: 0.4rem 0.8rem;
border-bottom: 1px solid var(--color-bg);
text-align: left;
font-size: 0.85rem;
}
.modal table th { background: var(--color-bg); }
.modal-close {
float: right;
background: none;
border: none;
font-size: 1.2rem;
cursor: pointer;
color: var(--color-lightgray);
}
.modal-close:hover { color: var(--color-darkgray); }
</style>
</head>
<body>
{% set page_title = 'Auswertungen — Bundesland × Partei × Wahlperiode' %}
{% set header_nav = '<a href="/quellen">Quellen</a>' %}
{% include "_header.html" %}
<div style="background:#e8f4f8;border-left:4px solid #007a80;padding:0.6rem 1.2rem;font-size:0.9rem;color:#333;">
Diese Seite ist auch direkt in der Haupt-App verfügbar: <a href="/?mode=auswertungen" style="color:#007a80;">zur integrierten Auswertungs-Ansicht →</a>
</div>
<main>
<div class="controls">
<label>Wahlperiode:
<select id="wp-filter">
<option value="">— alle WPs —</option>
{% for wp in wahlperioden %}
<option value="{{ wp }}">{{ wp }}</option>
{% endfor %}
</select>
</label>
<button id="reload">Anwenden</button>
<button class="export" id="export-csv">CSV-Export (alle Daten)</button>
</div>
<div id="matrix-container" class="matrix-wrap">
<div class="empty-state">Lade Matrix …</div>
</div>
<div class="meta" id="meta"></div>
</main>
<!-- Drill-down modal: Zeitreihe für eine (BL, Partei)-Kombination -->
<div class="modal-backdrop" id="modal-backdrop" onclick="closeModal(event)">
<div class="modal" onclick="event.stopPropagation()">
<button class="modal-close" onclick="closeModal()">&times;</button>
<h2 id="modal-title">Zeitreihe</h2>
<div id="modal-body">Lade …</div>
</div>
</div>
<h2 style="margin-top:2rem;color:var(--color-blue);">Thema × Fraktion</h2>
<p style="font-size:0.85rem;color:#666;margin-bottom:1rem;">Ø-GWÖ-Score pro Thema und Fraktion. Klick auf eine Zelle für Details. Grün = GWÖ-freundlich, Rot = GWÖ-kritisch.</p>
<div id="themen-container"><div class="empty-state">Lade Themen-Matrix …</div></div>
<script src="/static/chart.umd.min.js"></script>
<script>
const wpFilter = document.getElementById('wp-filter');
const reloadBtn = document.getElementById('reload');
const exportBtn = document.getElementById('export-csv');
const container = document.getElementById('matrix-container');
const meta = document.getElementById('meta');
function scoreClass(avg) {
if (avg === null || avg === undefined) return '';
if (avg >= 6) return 'score-high';
if (avg >= 3) return 'score-mid';
return 'score-low';
}
async function loadMatrix() {
const wp = wpFilter.value;
const url = wp
? `/api/auswertungen/matrix?wahlperiode=${encodeURIComponent(wp)}`
: '/api/auswertungen/matrix';
container.innerHTML = '<div class="empty-state">Lade Matrix …</div>';
try {
const r = await fetch(url);
const data = await r.json();
if (!data.bundeslaender.length) {
container.innerHTML = '<div class="empty-state">Keine Assessments für diesen Filter.</div>';
meta.textContent = '';
return;
}
let html = '<table class="matrix"><thead><tr><th class="row-header">Bundesland</th>';
for (const partei of data.parteien) {
html += `<th>${partei}</th>`;
}
html += '</tr></thead><tbody>';
for (const bl of data.bundeslaender) {
html += `<tr><th class="row-header">${bl}</th>`;
for (const partei of data.parteien) {
const cell = (data.cells[bl] || {})[partei];
if (cell) {
// Click → drill-down auf die Zeitreihe für diese Zelle
html += `<td class="cell-with-data ${scoreClass(cell.avg)}" data-bl="${bl}" data-partei="${partei}" onclick="showZeitreihe(this.dataset.bl, this.dataset.partei)">${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small></td>`;
} else {
html += '<td class="empty">—</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
meta.textContent = `${data.total} Assessment(s) | Filter: ${data.filter_wp || 'alle WPs'}`;
} catch (e) {
container.innerHTML = `<div class="empty-state">Fehler beim Laden: ${e}</div>`;
}
}
reloadBtn.addEventListener('click', loadMatrix);
wpFilter.addEventListener('change', loadMatrix);
exportBtn.addEventListener('click', () => {
window.location.href = '/api/auswertungen/export.csv';
});
// Zeitreihen-Modal: zeigt die Score-Entwicklung einer (BL, Partei)-
// Kombination über alle bekannten Wahlperioden hinweg.
async function showZeitreihe(bundesland, partei) {
const backdrop = document.getElementById('modal-backdrop');
const title = document.getElementById('modal-title');
const body = document.getElementById('modal-body');
title.textContent = `${bundesland} × ${partei}`;
body.innerHTML = '<p>Lade Zeitreihe …</p>';
backdrop.classList.add('show');
try {
const r = await fetch(`/api/auswertungen/zeitreihe?bundesland=${encodeURIComponent(bundesland)}&partei=${encodeURIComponent(partei)}`);
const z = await r.json();
if (!z.wahlperioden || !z.wahlperioden.length) {
body.innerHTML = '<p style="color:#888;">Keine Daten für diese Kombination.</p>';
return;
}
// Chart + Tabelle
body.innerHTML = '<canvas id="zeitreihe-chart" style="max-height:300px;margin-bottom:1rem;"></canvas>' +
'<table><thead><tr><th>Wahlperiode</th><th>Anträge</th><th>Ø GWÖ-Score</th></tr></thead><tbody>' +
z.wahlperioden.map(row => `<tr><td>${row.wp}</td><td>${row.n}</td><td><strong>${row.avg.toFixed(2)}</strong></td></tr>`).join('') +
'</tbody></table>';
// Chart.js rendern
if (window.Chart) {
const ctx = document.getElementById('zeitreihe-chart');
new Chart(ctx, {
type: 'line',
data: {
labels: z.wahlperioden.map(r => 'WP ' + r.wp),
datasets: [{
label: `Ø GWÖ-Score ${partei} (${bundesland})`,
data: z.wahlperioden.map(r => r.avg),
borderColor: '#009da5',
backgroundColor: 'rgba(0,157,165,0.1)',
fill: true,
tension: 0.3,
pointRadius: 5,
}]
},
options: {
responsive: true,
scales: {
y: { min: 0, max: 10, title: { display: true, text: 'GWÖ-Score' } },
},
plugins: {
tooltip: {
callbacks: {
afterLabel: (ctx) => `n=${z.wahlperioden[ctx.dataIndex].n} Anträge`
}
}
}
}
});
}
} catch (e) {
body.innerHTML = `<p style="color:#d00;">Fehler: ${e}</p>`;
}
}
function closeModal(ev) {
// Klick aufs Backdrop schließt; Klicks im Modal nicht
if (!ev || ev.target.id === 'modal-backdrop') {
document.getElementById('modal-backdrop').classList.remove('show');
} else if (!ev.target) {
document.getElementById('modal-backdrop').classList.remove('show');
}
}
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') document.getElementById('modal-backdrop').classList.remove('show');
});
loadMatrix();
loadThemenMatrix();
async function loadThemenMatrix() {
const container = document.getElementById('themen-container');
try {
const r = await fetch('/api/auswertungen/themen-matrix');
const data = await r.json();
if (!data.themen.length) {
container.innerHTML = '<div class="empty-state">Noch zu wenige Assessments für Themen-Analyse.</div>';
return;
}
let html = '<table class="matrix"><thead><tr><th class="row-header">Thema</th>';
for (const frak of data.fraktionen) {
html += `<th>${frak}</th>`;
}
html += '</tr></thead><tbody>';
for (const thema of data.themen) {
html += `<tr><th class="row-header">${thema}</th>`;
for (const frak of data.fraktionen) {
const cell = (data.cells[thema] || {})[frak];
if (cell) {
const cls = scoreClass(cell.avg);
html += `<td class="cell-with-data ${cls}" title="${thema} × ${frak}: Ø ${cell.avg}/10 (${cell.n} Anträge)">${cell.avg.toFixed(1)}<br><small>n=${cell.n}</small></td>`;
} else {
html += '<td class="empty">—</td>';
}
}
html += '</tr>';
}
html += '</tbody></table>';
container.innerHTML = html;
} catch (e) {
container.innerHTML = `<div class="empty-state">Fehler: ${e}</div>`;
}
}
</script>
</body>
</html>