gwoe-antragspruefer/app/templates/v2/screens/admin_stand.html
Dotty Dotter d30fcb132a feat: Stand-Dashboard, Score-Histogram, PM-Markdown, Live-Polling, Cluster-Indicator
Sechs zusammengehoerige UX/Performance-Erweiterungen:

**1. /v2/admin/stand — System-Stand-Dashboard**
KPI-Kacheln (Bewertungen, Plenum-Votes, Match, Vote-Orphans, News, PM-
Drafts, Bookmarks) + GWÖ-Score-Histogram + Per-BL-Tabelle + News-Source-
Tabelle. Auto-Refresh 30 s. Endpoint /api/admin/stand liefert alles in
einem Roundtrip. Nav-Eintrag "Stand" in der Admin-Sektion.

**2. /auswertungen Score-Histogram-Tab**
4. Tab "Score-Verteilung" mit Bar-Chart 0–10. Endpoint
/api/auswertungen/score-histogram liefert Buckets, optional gefiltert
nach Bundesland + Wahlperiode. Reagiert auf den globalen BL-Filter.

**3. PM-Body Markdown-Rendering**
Mini-Renderer im Modal: **bold** / __bold__ / *italic* / _italic_ /
- list-bullets / Doppel-Newline-Paragraphen. Kein externer Markdown-
Parser, keine neue Dependency. Body wird HTML-escaped, Patterns dann
zu Tags umgesetzt.

**4. Performance-Cache fuer themen_matching**
TTL-Cache (60 s) fuer aggregate_top_themen und aggregate_news_cluster.
Cache-Key inkl. aller Filter-Parameter. Automatische Invalidation in
news_aggregator.run_aggregator nach erfolgreichem Insert/Embed.
4 neue Tests fuer cache_get/set/clear-Verhalten.

**5. Stimmverhalten Banner Live-Update**
Statt setTimeout(800) jetzt pollQueueUntilDrained: alle 4 s
GET /api/queue/status, Banner zeigt pending + elapsed live. Bei
pending=0 zwei Polls in Folge: Banner + Stimmverhalten-Charts neu
laden. Max 5 Min Polling-Timeout. Bricht ab wenn Tab gewechselt wird.

**6. Antrag-Detail Cluster-Indicator**
News-Match-Box im Antrag-Detail laedt parallel /aktuelle-themen/cluster
und mappt URL → Cluster. Pro News-Card ein "🔗 Cluster (N News)"-Badge
mit Hover-Tooltip der anderen Cluster-Members. Macht thematische
Bündel sichtbar, ohne Pop-Out auf den Cluster-Tab.

Suite: 1088 → 1092 grün (4 Cache-Tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 02:49:06 +02:00

243 lines
7.8 KiB
HTML
Raw 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.

{% extends "v2/base.html" %}
{% block title %}System-Stand — GWÖ-Antragsprüfer{% endblock %}
{% set v2_active_nav = "admin_stand" %}
{% block head_extra %}
<script src="/static/chart.umd.min.js"></script>
<style>
.stand-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
margin-bottom: 1.5rem;
}
.stand-kpi {
background: var(--ecg-card-bg);
border: 1px solid var(--ecg-border);
border-radius: 6px;
padding: 16px;
text-align: center;
}
.stand-kpi-value {
font-family: var(--font-mono);
font-size: 30px;
font-weight: 700;
color: var(--ecg-teal);
line-height: 1.1;
}
.stand-kpi-label {
font-family: var(--font-mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
opacity: 0.6;
margin-top: 4px;
}
.stand-kpi-sub {
font-family: var(--font-mono);
font-size: 11px;
opacity: 0.55;
margin-top: 6px;
}
.stand-section {
margin-top: 2rem;
}
.stand-section h2 {
font-family: var(--font-display);
font-size: 16px;
color: var(--ecg-teal);
margin: 0 0 10px;
}
.stand-table {
width: 100%;
border-collapse: collapse;
font-family: var(--font-mono);
font-size: 12px;
}
.stand-table td, .stand-table th {
border-bottom: 1px solid var(--ecg-border);
padding: 4px 8px;
text-align: left;
}
.stand-table th {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
opacity: 0.6;
font-weight: 700;
}
.stand-table td:nth-child(2) {
text-align: right;
}
</style>
{% endblock %}
{% block main %}
<div style="padding:0 0 1.5rem;">
<h1 style="font-family:var(--font-display);font-size:22px;color:var(--ecg-teal);margin:0 0 4px;">System-Stand</h1>
<p style="font-size:12px;font-family:var(--font-mono);color:var(--ecg-dark);opacity:0.6;">
Datenüberblick · automatische Aktualisierung alle 30 s
</p>
</div>
<div id="stand-loading" style="font-family:var(--font-mono);font-size:12px;opacity:0.5;padding:16px 0;">Lade Stand …</div>
<div id="stand-content" style="display:none;">
<!-- KPI-Kacheln -->
<div class="stand-grid">
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-ass"></div>
<div class="stand-kpi-label">Bewertungen</div>
<div class="stand-kpi-sub" id="kpi-ass-7d">— in 7 Tagen</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-votes"></div>
<div class="stand-kpi-label">Plenum-Votes</div>
<div class="stand-kpi-sub">aus Plenarprotokollen</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-match"></div>
<div class="stand-kpi-label">Bewertung ∩ Vote</div>
<div class="stand-kpi-sub" id="kpi-orphans">— Vote-Orphans</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-news"></div>
<div class="stand-kpi-label">News</div>
<div class="stand-kpi-sub" id="kpi-news-7d">— in 7 Tagen</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-drafts"></div>
<div class="stand-kpi-label">PM-Entwürfe</div>
<div class="stand-kpi-sub" id="kpi-drafts-7d">— in 7 Tagen</div>
</div>
<div class="stand-kpi">
<div class="stand-kpi-value" id="kpi-bookmarks"></div>
<div class="stand-kpi-label">Merklisten-Einträge</div>
<div class="stand-kpi-sub">user-übergreifend</div>
</div>
</div>
<!-- Score-Histogram -->
<div class="stand-section">
<h2>GWÖ-Score-Verteilung</h2>
<div style="background:var(--ecg-card-bg);border:1px solid var(--ecg-border);border-radius:6px;padding:14px;">
<canvas id="stand-score-chart" style="max-height:240px;"></canvas>
</div>
</div>
<!-- Per-Bundesland -->
<div class="stand-section">
<h2>Bewertungen + Votes pro Bundesland</h2>
<table class="stand-table">
<thead><tr><th>BL</th><th>Bewertungen</th><th>Plenum-Votes</th></tr></thead>
<tbody id="stand-bl-rows"></tbody>
</table>
</div>
<!-- News-Quellen -->
<div class="stand-section">
<h2>News pro Quelle</h2>
<table class="stand-table">
<thead><tr><th>Quelle</th><th>Anzahl</th></tr></thead>
<tbody id="stand-news-rows"></tbody>
</table>
</div>
<div id="stand-meta" style="font-family:var(--font-mono);font-size:11px;opacity:0.5;margin-top:1.5rem;"></div>
</div>
{% endblock %}
{% block body_scripts %}
<script>
let _scoreChart = null;
function fmtN(n) {
return (n == null) ? '—' : n.toLocaleString('de-DE');
}
async function loadStand() {
try {
const r = await fetch('/api/admin/stand');
if (!r.ok) {
document.getElementById('stand-loading').textContent = 'Fehler ' + r.status;
return;
}
const d = await r.json();
document.getElementById('stand-loading').style.display = 'none';
document.getElementById('stand-content').style.display = '';
// KPIs
document.getElementById('kpi-ass').textContent = fmtN(d.assessments.total);
document.getElementById('kpi-ass-7d').textContent = '+' + fmtN(d.assessments.last_7_days) + ' in 7 Tagen';
document.getElementById('kpi-votes').textContent = fmtN(d.plenum_votes.total);
document.getElementById('kpi-match').textContent = fmtN(d.match.with_assessment_and_vote);
document.getElementById('kpi-orphans').textContent = fmtN(d.match.vote_orphans) + ' Vote-Orphans';
document.getElementById('kpi-news').textContent = fmtN(d.news.total);
document.getElementById('kpi-news-7d').textContent =
'+' + fmtN(d.news.last_7_days) + ' in 7 Tagen, ' + fmtN(d.news.embedded) + ' embedded';
document.getElementById('kpi-drafts').textContent = fmtN(d.presse_drafts.total);
document.getElementById('kpi-drafts-7d').textContent = '+' + fmtN(d.presse_drafts.last_7_days) + ' in 7 Tagen';
document.getElementById('kpi-bookmarks').textContent = fmtN(d.bookmarks);
// Score-Histogram
const dist = d.assessments.score_distribution || {};
const buckets = [0,1,2,3,4,5,6,7,8,9,10];
const values = buckets.map(b => dist[String(b)] || 0);
const colors = buckets.map(b => {
if (b <= 2) return 'rgba(200,30,30,0.6)';
if (b <= 4) return 'rgba(247,148,29,0.6)';
if (b <= 6) return 'rgba(150,150,150,0.5)';
if (b <= 8) return 'rgba(136,158,51,0.6)';
return 'rgba(0,157,165,0.7)';
});
if (_scoreChart) _scoreChart.destroy();
_scoreChart = new Chart(document.getElementById('stand-score-chart'), {
type: 'bar',
data: { labels: buckets.map(b => b + '' + (b+1)), datasets: [{
label: 'Bewertungen',
data: values,
backgroundColor: colors,
}]},
options: {
responsive: true,
plugins: { legend: { display: false } },
scales: {
y: { beginAtZero: true, title: { display: true, text: 'Anzahl' } },
x: { title: { display: true, text: 'GWÖ-Score-Bucket' } },
},
},
});
// Per-BL-Tabelle
const ass_bl = d.assessments.by_bundesland || {};
const vote_bl = d.plenum_votes.by_bundesland || {};
const bl_set = new Set([...Object.keys(ass_bl), ...Object.keys(vote_bl)]);
const bl_rows = [...bl_set].sort();
document.getElementById('stand-bl-rows').innerHTML = bl_rows.map(bl => `
<tr>
<td>${bl}</td>
<td>${fmtN(ass_bl[bl] || 0)}</td>
<td>${fmtN(vote_bl[bl] || 0)}</td>
</tr>`).join('');
// News-Source-Tabelle
const ns = d.news.by_source || {};
document.getElementById('stand-news-rows').innerHTML =
Object.entries(ns).sort((a, b) => b[1] - a[1]).map(([s, n]) => `
<tr><td>${s}</td><td>${fmtN(n)}</td></tr>`).join('');
document.getElementById('stand-meta').textContent =
'Aktualisiert: ' + new Date().toLocaleTimeString('de-DE');
} catch (e) {
document.getElementById('stand-loading').textContent = 'Fehler: ' + e;
}
}
loadStand();
setInterval(loadStand, 30000);
</script>
{% endblock %}