feat: Scorecard Portrait — Claude-Design 1:1 übernommen (1080×1350)

User hat eine Design-Vorgabe als ZIP geliefert (GWÖ Antrag Score Card.zip,
Claude Design Output, Stand 2026-05-07). Komplett anderes Konzept als
mein vorheriges Layout — nicht eine 5×5-Matrix als Datenviz, sondern eine
editorial-magazin-mässige Card mit klaren Modulen.

Übernommen 1:1:
- Inter + JetBrains Mono via Google Fonts
- 3-Zonen-Grid: Header 88px / Body / Footer 96px
- Paper-BG #f5f1ea, Ink #1a1a1a, GWÖ-Grün gedeckt #0a5d3f
- Header-Strip: Brand-Dot + 'GWÖ-Antragsprüfer' / 'Matrix 2.0' / 'NRW · WP18'
- Topline: Drs.-ID + Antragsteller-Pill (Partei-Farbpunkt)
- H1 Antragstitel 78pt, weight 800, line-height 0.95
- Lede: 1-Satz-Zusammenfassung, max 180 Zeichen
- Score-Block: 320px breite gruene Kachel mit 9.0/10 + 'Empfehlung' kicker
  + Verdict-Text + 3 Schwerpunkt-Chips (Code + Wert-kurz + Symbol)
- Fraktions-Grid: bis zu 5 Spalten, pro Fraktion Name + WP-Bar + Score
  + Vote-Label (Ja ✓ / Nein ✗); weak-Klasse (rot) bei Score<5 oder Nein
- Decision-Bar: invertiert schwarz, Beschluss-Text mit ✓/✗-Akzent
- Footer: URL + /antrag/-Pfad + CC BY 4.0 + QR-Pattern

Datenaggregation in main.py erweitert:
- matrix_chips: Top-3 positive Felder (rating > 0) mit Code+Wert+Symbol
- fraktionen_bars: aus wahlprogramm_scores + plenum_votes-Lookup
  (WP-Bar + Vote-Label pro Fraktion)
- beschluss: aus erstem plenum_vote (ergebnis + Mehrheits-Verhältnis)
- wahlperiode: via wahlperioden.wahlperiode_for(datum, BL)

Routing:
- /v2/scorecard?format=portrait → scorecard_portrait.html (NEU)
- /v2/scorecard?format=square|og → scorecard.html (alt, unveraendert)
- /api/assessment/scorecard.png?format=portrait nutzt ebenfalls das neue
  Portrait-Template fuer die PNG-Generierung.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dotty Dotter 2026-05-07 13:55:16 +02:00
parent 79e7937d51
commit 1350fc7f52
2 changed files with 438 additions and 12 deletions

View File

@ -3540,7 +3540,111 @@ async def scorecard_template(
} }
width, height = dimensions.get(format, dimensions["og"]) width, height = dimensions.get(format, dimensions["og"])
response = templates.TemplateResponse("v2/screens/scorecard.html", { # ─── Cloud-Design (portrait) braucht aggregierte Hilfsdaten ──────
# Chips: Top-3 positiv bewerteter Felder mit Code + Wert-Kurzname + Symbol
werte_kurz = {
"1": "Würde",
"2": "Solidarität",
"3": "Nachhaltigkeit",
"4": "Soz. Gerechtigkeit",
"5": "Transparenz",
}
def _sym(r: int) -> str:
if r >= 4: return "++"
if r >= 1: return "+"
if r == 0: return ""
if r <= -4: return ""
return ""
matrix_chips = []
sorted_matrix = sorted(assessment.gwoe_matrix, key=lambda e: e.rating, reverse=True)
for e in sorted_matrix:
if e.rating <= 0:
break
col = e.field[1:] if len(e.field) >= 2 else ""
matrix_chips.append({
"code": e.field,
"label_short": werte_kurz.get(col, ""),
"symbol": _sym(e.rating),
})
if len(matrix_chips) >= 3:
break
# Wahlperiode aus datum + bundesland (best-effort)
wahlperiode = ""
try:
from .wahlperioden import wahlperiode_for
wahlperiode = wahlperiode_for(row.get("datum", ""), bundesland) or ""
except Exception:
pass
# Fraktions-Bars: pro WP-Score-Eintrag eine Bar mit Vote-Label.
# Plenum-Votes best-effort laden (nur erstes Vote-Ergebnis fuer Beschluss).
plenum_votes_list: list[dict] = []
try:
from .database import get_plenum_votes as _gpv
plenum_votes_list = await _gpv(bundesland or "NRW", drucksache)
except Exception:
pass
vote_lookup: dict[str, str] = {}
if plenum_votes_list:
v0 = plenum_votes_list[0]
for f in (v0.get("fraktionen_ja") or []): vote_lookup[str(f).upper()] = "ja"
for f in (v0.get("fraktionen_nein") or []): vote_lookup[str(f).upper()] = "nein"
for f in (v0.get("fraktionen_enthaltung") or []): vote_lookup[str(f).upper()] = "enth"
fraktionen_bars = []
for fs in (assessment.wahlprogramm_scores or [])[:5]:
wp_score = (fs.wahlprogramm.score if fs.wahlprogramm else 0) or 0
vote = vote_lookup.get(str(fs.fraktion).upper())
if vote == "ja":
vote_label, vote_class = "Ja ✓", "ja"
elif vote == "nein":
vote_label, vote_class = "Nein ✗", "nein"
elif vote == "enth":
vote_label, vote_class = "Enth.", "enth"
else:
vote_label, vote_class = "", "unbekannt"
fraktionen_bars.append({
"name": fs.fraktion,
"bar_pct": int(min(100, max(0, wp_score * 10))),
"score_text": f"{int(round(wp_score))}/10",
"vote_label": vote_label,
"vote_class": vote_class,
"weak": wp_score < 5 or vote == "nein",
})
# Beschluss-Bar
beschluss = None
if plenum_votes_list:
v0 = plenum_votes_list[0]
ergebnis = (v0.get("ergebnis") or "").strip().lower()
n_ja = len(v0.get("fraktionen_ja") or [])
n_nein = len(v0.get("fraktionen_nein") or [])
is_positive = ergebnis in ("angenommen", "bestaetigt", "bestätigt")
ergebnis_text = (v0.get("ergebnis") or "").capitalize() or ("Angenommen" if is_positive else "Abgelehnt")
if v0.get("einstimmig"):
mehrheit_text = "einstimmig"
elif n_ja or n_nein:
mehrheit_text = f"{n_ja}:{n_nein} Fraktionen"
else:
mehrheit_text = ""
beschluss = {
"is_positive": is_positive,
"text": f"{ergebnis_text}" + (f"{mehrheit_text}" if mehrheit_text else ""),
}
score_color_band = "good" if score >= 7 else "mid" if score >= 4 else "low"
# Template-Wahl: portrait nutzt das Cloud-Design (eigene Datei),
# square/og bleiben beim alten Layout.
template_name = (
"v2/screens/scorecard_portrait.html"
if format == "portrait"
else "v2/screens/scorecard.html"
)
response = templates.TemplateResponse(template_name, {
"request": request, "request": request,
"assessment": assessment, "assessment": assessment,
"bundesland": bundesland, "bundesland": bundesland,
@ -3548,6 +3652,13 @@ async def scorecard_template(
"fraktionen": fraktionen[:4], "fraktionen": fraktionen[:4],
"datum": (row.get("datum") or "")[:10], "datum": (row.get("datum") or "")[:10],
"score_color": score_color, "score_color": score_color,
"score_color_band": score_color_band,
"matrix_chips": matrix_chips,
"fraktionen_bars": fraktionen_bars,
"fraktionen_count": max(1, len(fraktionen_bars)),
"beschluss": beschluss,
"antrag_typ": (row.get("typ") or "Antrag"),
"wahlperiode": wahlperiode,
"width": width, "width": width,
"height": height, "height": height,
}) })
@ -3592,17 +3703,102 @@ async def _render_scorecard_pdf(
elif score >= 5: score_color = "#bf6c10" elif score >= 5: score_color = "#bf6c10"
else: score_color = "#9a2a2a" else: score_color = "#9a2a2a"
template = templates.env.get_template("v2/screens/scorecard.html") # Portrait nutzt das Cloud-Design-Template; square/og das alte.
html_content = template.render( # Fuer Portrait brauchen wir die gleichen Aggregat-Daten wie der
request=None, # HTML-Render (Chips, Fraktions-Bars, Beschluss).
assessment=assessment, if format == "portrait":
bundesland=bundesland, werte_kurz = {
matrix_lookup=matrix_lookup, "1": "Würde", "2": "Solidarität", "3": "Nachhaltigkeit",
fraktionen=fraktionen[:4], "4": "Soz. Gerechtigkeit", "5": "Transparenz",
datum=(row.get("datum") or "")[:10], }
score_color=score_color, def _sym(r: int) -> str:
width=width, height=height, if r >= 4: return "++"
) if r >= 1: return "+"
if r == 0: return ""
if r <= -4: return ""
return ""
matrix_chips = []
for e in sorted(assessment.gwoe_matrix, key=lambda x: x.rating, reverse=True):
if e.rating <= 0: break
col = e.field[1:] if len(e.field) >= 2 else ""
matrix_chips.append({
"code": e.field,
"label_short": werte_kurz.get(col, ""),
"symbol": _sym(e.rating),
})
if len(matrix_chips) >= 3: break
try:
from .wahlperioden import wahlperiode_for
wahlperiode = wahlperiode_for(row.get("datum", ""), bundesland) or ""
except Exception:
wahlperiode = ""
plenum_votes_list: list[dict] = []
try:
from .database import get_plenum_votes as _gpv
plenum_votes_list = await _gpv(bundesland or "NRW", drucksache)
except Exception:
pass
vote_lookup: dict[str, str] = {}
if plenum_votes_list:
v0 = plenum_votes_list[0]
for f in (v0.get("fraktionen_ja") or []): vote_lookup[str(f).upper()] = "ja"
for f in (v0.get("fraktionen_nein") or []): vote_lookup[str(f).upper()] = "nein"
for f in (v0.get("fraktionen_enthaltung") or []): vote_lookup[str(f).upper()] = "enth"
fraktionen_bars = []
for fs in (assessment.wahlprogramm_scores or [])[:5]:
wp_score = (fs.wahlprogramm.score if fs.wahlprogramm else 0) or 0
vote = vote_lookup.get(str(fs.fraktion).upper())
if vote == "ja": vote_label, vote_class = "Ja ✓", "ja"
elif vote == "nein": vote_label, vote_class = "Nein ✗", "nein"
elif vote == "enth": vote_label, vote_class = "Enth.", "enth"
else: vote_label, vote_class = "", "unbekannt"
fraktionen_bars.append({
"name": fs.fraktion,
"bar_pct": int(min(100, max(0, wp_score * 10))),
"score_text": f"{int(round(wp_score))}/10",
"vote_label": vote_label, "vote_class": vote_class,
"weak": wp_score < 5 or vote == "nein",
})
beschluss = None
if plenum_votes_list:
v0 = plenum_votes_list[0]
ergebnis = (v0.get("ergebnis") or "").strip().lower()
n_ja = len(v0.get("fraktionen_ja") or [])
n_nein = len(v0.get("fraktionen_nein") or [])
is_positive = ergebnis in ("angenommen", "bestaetigt", "bestätigt")
ergebnis_text = (v0.get("ergebnis") or "").capitalize() or ("Angenommen" if is_positive else "Abgelehnt")
mehrheit_text = "einstimmig" if v0.get("einstimmig") else (f"{n_ja}:{n_nein} Fraktionen" if (n_ja or n_nein) else "")
beschluss = {
"is_positive": is_positive,
"text": ergebnis_text + (f"{mehrheit_text}" if mehrheit_text else ""),
}
score_color_band = "good" if score >= 7 else "mid" if score >= 4 else "low"
template = templates.env.get_template("v2/screens/scorecard_portrait.html")
html_content = template.render(
request=None,
assessment=assessment,
bundesland=bundesland,
matrix_chips=matrix_chips,
fraktionen_bars=fraktionen_bars,
fraktionen_count=max(1, len(fraktionen_bars)),
beschluss=beschluss,
antrag_typ=(row.get("typ") or "Antrag"),
wahlperiode=wahlperiode,
score_color_band=score_color_band,
width=width, height=height,
)
else:
template = templates.env.get_template("v2/screens/scorecard.html")
html_content = template.render(
request=None,
assessment=assessment,
bundesland=bundesland,
matrix_lookup=matrix_lookup,
fraktionen=fraktionen[:4],
datum=(row.get("datum") or "")[:10],
score_color=score_color,
width=width, height=height,
)
# `size: NNNpt` → PDF-Page hat exakt N×M Punkte. PyMuPDF rendert # `size: NNNpt` → PDF-Page hat exakt N×M Punkte. PyMuPDF rendert
# bei zoom=1 dann 1 PDF-Punkt = 1 PNG-Pixel. CSS-Pixel werden # bei zoom=1 dann 1 PDF-Punkt = 1 PNG-Pixel. CSS-Pixel werden

View File

@ -0,0 +1,230 @@
{# scorecard_portrait.html — 1080×1350 Hochkant-Scorecard (Instagram 4:5)
Designvorgabe: Claude Design, ZIP "GWÖ Antrag Score Card.zip" vom 2026-05-07.
Übernommen 1:1 in HTML/CSS, Jinja-Variablen ersetzen die Beispiel-Inhalte.
Komprimierung gegenüber der Profi-Detail-Ansicht (siehe Spec-Sheet):
- 5×5-Matrix → Top-3 Schwerpunkt-Chips
- Zitate, Verbesserungen, Kommentare, News alle raus
- Stärken/Schwächen-Fließtext → 1-Satz-Zusammenfassung
- Fraktions-Bewertung als Balken-Grid (5 Spalten) statt langer Liste
- Beschluss als invertierte schwarze Bar am Ende
#}
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8" />
<title>GWÖ-Scorecard Drs. {{ assessment.drucksache }}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<style>
:root{
--ink:#1a1a1a;
--paper:#f5f1ea;
--rule:#1a1a1a;
--muted:#6b6660;
--accent:#0a5d3f;
--accent-warn:#b04a2f;
--grid:#d9d2c5;
--score-bg: {% if score_color_band == 'good' %}#0a5d3f{% elif score_color_band == 'mid' %}#6b6660{% else %}#b04a2f{% endif %};
}
*{box-sizing:border-box;margin:0;padding:0}
html,body{background:var(--paper);color:var(--ink);font-family:'Inter',sans-serif;}
body{
width:{{ width }}px;height:{{ height }}px;overflow:hidden;
}
.card{
width:1080px;height:1350px;
background:var(--paper);color:var(--ink);
position:relative;overflow:hidden;
display:grid;grid-template-rows: 88px 1fr 96px;
}
/* Kopfleiste */
.head{
border-bottom:2px solid var(--rule);
padding:0 56px;
display:flex;align-items:center;justify-content:space-between;
font-family:'JetBrains Mono',monospace;
font-size:18px;font-weight:500;letter-spacing:.04em;
text-transform:uppercase;
}
.head .brand{display:flex;align-items:center;gap:14px}
.head .dot{width:14px;height:14px;background:var(--accent);border-radius:50%}
.head .meta{display:flex;gap:24px;color:var(--muted)}
/* Body */
.body{padding:40px 56px 32px;display:flex;flex-direction:column;gap:28px;min-height:0}
.topline{display:flex;justify-content:space-between;align-items:flex-start;gap:32px}
.ids{font-family:'JetBrains Mono',monospace;font-size:20px;color:var(--muted);letter-spacing:.06em}
.ids strong{color:var(--ink);font-weight:600}
.party-pill{
display:inline-flex;align-items:center;gap:10px;
padding:8px 18px;border:2px solid var(--rule);border-radius:999px;
font-weight:700;font-size:20px;letter-spacing:.04em;
}
.party-pill::before{content:"";width:12px;height:12px;border-radius:50%;background:#d44}
h1.title{
font-family:'Inter',sans-serif;
font-weight:800;font-size:78px;line-height:.95;
letter-spacing:-.025em;text-wrap:balance;
}
.lede{
font-size:26px;line-height:1.35;color:var(--ink);
max-width:880px;text-wrap:pretty;
}
.scorewrap{
display:grid;grid-template-columns: 320px 1fr;gap:32px;align-items:stretch;
border-top:2px solid var(--rule);border-bottom:2px solid var(--rule);
padding:24px 0;
}
.score{
background:var(--score-bg);color:var(--paper);
display:flex;flex-direction:column;justify-content:center;align-items:center;
padding:18px;border-radius:8px;
}
.score .num{font-size:120px;font-weight:800;line-height:1;letter-spacing:-.04em;font-variant-numeric:tabular-nums}
.score .num small{font-size:42px;opacity:.7;font-weight:600}
.score .label{font-family:'JetBrains Mono',monospace;font-size:15px;text-transform:uppercase;letter-spacing:.1em;margin-top:6px;opacity:.85}
.verdict{display:flex;flex-direction:column;justify-content:center;gap:14px}
.verdict .kicker{font-family:'JetBrains Mono',monospace;font-size:15px;text-transform:uppercase;letter-spacing:.12em;color:var(--muted)}
.verdict .vtext{font-size:34px;font-weight:700;line-height:1.1;letter-spacing:-.01em}
.verdict .fields{display:flex;gap:8px;flex-wrap:wrap;margin-top:4px}
.field-chip{
font-family:'JetBrains Mono',monospace;font-size:16px;font-weight:600;
border:1.5px solid var(--rule);padding:4px 10px;border-radius:4px;background:transparent;
}
/* Fraktionen Grid */
.fractions{display:grid;grid-template-columns:repeat({{ fraktionen_count }},1fr);gap:0;border:1.5px solid var(--rule)}
.frac{
padding:16px 14px;border-right:1.5px solid var(--rule);
display:flex;flex-direction:column;gap:8px;align-items:center;text-align:center;
background:var(--paper);
}
.frac:last-child{border-right:none}
.frac .name{font-family:'JetBrains Mono',monospace;font-size:17px;font-weight:700;letter-spacing:.05em}
.frac .bar{height:8px;width:100%;background:#e6dfd1;border-radius:2px;overflow:hidden}
.frac .bar > span{display:block;height:100%;background:var(--accent)}
.frac.weak .bar > span{background:var(--accent-warn)}
.frac .val{font-family:'JetBrains Mono',monospace;font-size:18px;font-weight:600}
.frac .vote{font-size:14px;font-weight:700;letter-spacing:.08em;text-transform:uppercase}
.vote.ja{color:var(--accent)}
.vote.nein{color:var(--accent-warn)}
.vote.enth{color:var(--muted)}
.vote.unbekannt{color:var(--muted);opacity:.6}
/* Beschluss */
.decision{
display:flex;align-items:center;justify-content:space-between;
padding:18px 22px;background:var(--ink);color:var(--paper);
border-radius:6px;
}
.decision .left{font-family:'JetBrains Mono',monospace;font-size:16px;letter-spacing:.1em;text-transform:uppercase;opacity:.7}
.decision .right{font-size:24px;font-weight:700}
.decision .right .x{color:#ff6a4a;margin-right:10px}
.decision .right .ok{color:#7fd9a8;margin-right:10px}
/* Footer */
.foot{
border-top:2px solid var(--rule);
padding:0 56px;
display:flex;align-items:center;justify-content:space-between;
font-family:'JetBrains Mono',monospace;font-size:16px;letter-spacing:.04em;
color:var(--muted);
}
.foot .url{color:var(--ink);font-weight:600}
.foot .qr{width:64px;height:64px;background:
repeating-conic-gradient(var(--ink) 0 25%, var(--paper) 0 50%) 50%/16px 16px;
border:2px solid var(--ink);
}
</style>
</head>
<body>
<div class="card">
<header class="head">
<div class="brand"><span class="dot"></span>GWÖ-Antragsprüfer</div>
<div class="meta">
<span>Matrix 2.0</span>
<span>{{ bundesland }}{% if wahlperiode %} · WP{{ wahlperiode }}{% endif %}</span>
</div>
</header>
<main class="body">
<div class="topline">
<div class="ids">
<strong>Drs. {{ assessment.drucksache }}</strong>
{% if antrag_typ %} · {{ antrag_typ }}{% endif %}
{% if assessment.datum %} · eingebracht {{ assessment.datum }}{% endif %}
</div>
{% if assessment.fraktionen %}
<div class="party-pill">{{ assessment.fraktionen[0] }}</div>
{% endif %}
</div>
<h1 class="title">{{ assessment.title|truncate(80, end="…") }}</h1>
{% if assessment.antrag_zusammenfassung %}
<p class="lede">{{ assessment.antrag_zusammenfassung|truncate(180, end="…") }}</p>
{% endif %}
<div class="scorewrap">
<div class="score">
<div class="num">{{ "%.1f"|format(assessment.gwoe_score) }}<small>/10</small></div>
<div class="label">GWÖ-Score</div>
</div>
<div class="verdict">
<div class="kicker">Empfehlung</div>
<div class="vtext">{{ assessment.empfehlung.value }}</div>
{% if matrix_chips %}
<div class="fields">
{% for chip in matrix_chips %}
<span class="field-chip">{{ chip.code }} {{ chip.label_short }} {{ chip.symbol }}</span>
{% endfor %}
</div>
{% endif %}
</div>
</div>
{% if fraktionen_bars %}
<div class="fractions">
{% for f in fraktionen_bars %}
<div class="frac{% if f.weak %} weak{% endif %}">
<div class="name">{{ f.name }}</div>
<div class="bar"><span style="width:{{ f.bar_pct }}%"></span></div>
<div class="val">{{ f.score_text }}</div>
<div class="vote {{ f.vote_class }}">{{ f.vote_label }}</div>
</div>
{% endfor %}
</div>
{% endif %}
{% if beschluss %}
<div class="decision">
<div class="left">Beschluss Plenum</div>
<div class="right">
<span class="{{ 'ok' if beschluss.is_positive else 'x' }}">{{ '✓' if beschluss.is_positive else '✗' }}</span>
{{ beschluss.text }}
</div>
</div>
{% endif %}
</main>
<footer class="foot">
<div><span class="url">gwoe.toppyr.de</span> · /antrag/{{ assessment.drucksache }}</div>
<div>CC BY 4.0</div>
<div class="qr" aria-hidden="true"></div>
</footer>
</div>
</body>
</html>