Files
bin/docs/superpowers/plans/2026-07-20-ausbaustufe-7.md
2026-07-20 14:21:11 +02:00

377 lines
19 KiB
Markdown
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.
# Ausbaustufe 7 Implementation Plan — Szenarien-Seite, Formular-UX, Kategorien (v0.8.0)
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
**Goal:** Szenarien auf eigene Seite `/szenarien`, verständliches Eintrags-Formular (Paar-Layout, Gesperrt-Kennzeichnung, dynamisches Wert-Label, abgesetzter Durchrechnen-Button), Kategorien-Verwaltung auf der Admin-Seite; Release v0.8.0.
**Architecture:** Kein Datenmodell-/Migrationsbedarf. Template-Umzug der Szenarien-Sektion aus `planning.html` in neues `szenarien.html` + Route in `gui.py`; CSS-Regeln in `style.css`; ein neuer Endpunkt `PATCH /api/categories/{id}`.
**Tech Stack:** FastAPI, Jinja2, htmx + json-form, CSS, pytest.
**Spec:** `docs/superpowers/specs/2026-07-20-szenarien-seite-ux-design.md`
## Global Constraints
- GUI deutsch, TT.MM.JJJJ, `|eur`; API-Werte englisch; Geldbeträge `Decimal`.
- UX-Regel: gesperrte Felder sichtbar + `disabled` + optisch gekennzeichnet; Edit-Zeilen per `hidden`.
- Fable-Testagent-Gate je Task VOR dem Commit; Ledger-Eintrag je Task in `.superpowers/sdd/progress.md`.
- DATENSCHUTZ: keine echten Kontodaten in Commits/Tests/Doku.
- Testlauf: `cd /home/wlfb/bin/finance && .venv/bin/python -m pytest -q` — Basis 181 passed, muss grün bleiben.
- Pfade relativ zu `/home/wlfb/bin/finance` (Ledger/Plan unter `/home/wlfb/bin`).
---
### Task 1: Szenarien-Seite `/szenarien`
**Files:**
- Create: `app/templates/szenarien.html`
- Modify: `app/templates/planning.html` (Szenarien-`<section>` Zeilen ~263-464 und szenario-spezifisches JS entfernen), `app/templates/base.html` (Nav + `toggleEdit`), `app/routers/gui.py` (neue Route, `planung_page` verschlanken), `app/templates/hilfe.html` (Ortsangaben)
- Modify: `tests/test_gui.py`
**Interfaces:**
- Consumes: bestehenden Szenario-Kontext (`_scenario_rows`, `modifier_kinds`), `gui_session`, alle bestehenden Templates/JS-Helfer.
- Produces: `GET /szenarien` (login-geschützt) mit Kontext `scenario_rows`, `loans`, `categories`, `category_names`, `recurring`, `recurring_names`, `modifier_kinds`; `toggleEdit` global in `base.html`. Task 2 baut auf `szenarien.html` auf.
- [x] **Step 1: Failing GUI-Tests** — in `tests/test_gui.py`:
```python
def test_szenarien_seite_und_nav(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="Seiten-Test", description="d"))
db.commit()
r = client.get("/szenarien")
assert r.status_code == 200
assert "Seiten-Test" in r.text and "Neues Szenario anlegen" in r.text
assert 'href="/szenarien"' in r.text # Nav-Punkt
# Planung enthaelt keine Szenarien-Sektion mehr:
p = client.get("/planung").text
assert "Seiten-Test" not in p and "Neues Szenario anlegen" not in p
assert "Wiederkehrende Posten" in p and "Kredite" in p
def test_szenarien_seite_braucht_login(client):
r = client.get("/szenarien", follow_redirects=False)
assert r.status_code in (302, 303) and r.headers["location"] == "/login"
```
Run: `.venv/bin/python -m pytest tests/test_gui.py -q` → neue Tests FAIL (404)
- [x] **Step 2: Route**`app/routers/gui.py`: in `planung_page` die Zeilen `scenarios = _list_scenarios(...)`, `"scenario_rows": ...` und `"modifier_kinds": ...` entfernen (die übrigen Kontexteinträge bleiben). Neue Route direkt darunter:
```python
@router.get("/szenarien", dependencies=[Depends(gui_session)])
def szenarien_page(request: Request, session: Session = Depends(get_session)):
categories = session.execute(select(Category)).scalars().all()
recurring = _list_recurring(session=session)
loans = _list_loans(session=session)
scenarios = _list_scenarios(session=session)
return templates.TemplateResponse(request, "szenarien.html", {
"categories": categories,
"category_names": {c.id: c.name for c in categories},
"recurring": recurring,
"recurring_names": {r.id: r.name for r in recurring},
"loans": loans,
"scenario_rows": _scenario_rows(session, scenarios, loans),
"modifier_kinds": ["percent", "absolute", "remove", "ende"],
})
```
- [x] **Step 3: Template-Umzug** — Neues `app/templates/szenarien.html`:
```html
{% extends "base.html" %}
{% block title %}Szenarien Finanzberatung{% endblock %}
{% block content %}
<h1>Szenarien</h1>
<section class="planning-section">
<!-- HIER: die komplette bisherige Szenarien-<section>-INNEREI aus
planning.html unverändert einfügen (alles zwischen
'<h2>Szenarien</h2>' … einschließlich des Fieldsets
'Neues Szenario anlegen'), ohne das umschließende <section>-Tag
doppelt zu setzen. -->
</section>
<script>
// Szenario-spezifische Helfer (aus planning.html hierher umgezogen):
// onModTargetTypeChange, onEntryArtChange, toggleScenarioLoan
// unverändert einfügen.
</script>
{% endblock %}
```
Der `<h2>Szenarien</h2>` entfällt (die Seite hat die `<h1>`-Überschrift). In `planning.html`: die gesamte Szenarien-`<section>` löschen; aus dem Script-Block `onModTargetTypeChange`, `onEntryArtChange`, `toggleScenarioLoan` und `toggleEdit` entfernen — `toggleEdit` zieht in den zentralen Script-Block von `base.html` (dort nach der json-form-Extension einfügen, unverändert inkl. Kommentar); `loadLoanSchedule`/`fmtEur`/`formatIsoDate` und `loadedSchedules` bleiben in `planning.html`.
- [x] **Step 4: Nav + Hilfe**`base.html`: `<a href="/szenarien">Szenarien</a>` zwischen Planung- und Admin-Link. `hilfe.html`: Formulierungen, die die Szenarien auf der Planung-Seite verorten, auf „Szenarien-Seite" anpassen (grep nach „Szenari" in `hilfe.html` und Ortsangaben prüfen).
- [x] **Step 5: Bestehende Tests umziehen**`tests/test_gui.py`: die Szenario-GUI-Tests (`test_szenario_gui_ende_und_einmalzahlungen`, `test_szenario_eintraege_tabelle`, Szenario-Anteile von `test_planung_hat_bearbeiten_formulare`) von `client.get("/planung")` auf `client.get("/szenarien")` umstellen. In `test_planung_hat_bearbeiten_formulare` den Scenario-Teil (Scenario-Seed + `hx-patch="/api/scenarios/`-Assertion) in einen neuen Test `test_szenarien_hat_bearbeiten_formular` auslagern, der `/szenarien` lädt; der Planung-Test behält rec/pln/loan mit `>= 3` Bearbeiten-Buttons. `test_pages_require_login`/`test_pages_render_after_login`: Pfad `/szenarien` in die jeweilige Pfadliste aufnehmen.
- [x] **Step 6: Suite grün**
Run: `.venv/bin/python -m pytest -q` → PASS
- [x] **Step 7: Fable-Testagent-Abnahme** (Live-Approximation: beide Seiten rendern; keine Szenario-Reste auf /planung; `toggleEdit` genau EINMAL definiert [base.html], auf beiden Seiten funktionale Edit-Formulare; keine JS-Referenzen auf entfernte Funktionen in planning.html; Nav auf allen Seiten). Erst nach VERIFIED weiter.
- [x] **Step 8: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/ finance/tests/test_gui.py
git commit -m "feat: eigene Szenarien-Seite /szenarien"
```
---
### Task 2: Formular-UX — Paar-Layout, Gesperrt-Kennzeichnung, klare Struktur
**Files:**
- Modify: `app/static/style.css`, `app/templates/szenarien.html`, `tests/test_gui.py`
**Interfaces:**
- Consumes: `szenarien.html` aus Task 1 (Neuer-Eintrag-Formular mit Feldern kind/target_type/target_id/value/end_date/name/amount/due; `onEntryArtChange`).
- Produces: CSS-Klassen `.entry-form`, `.entry-art`, `.value-label`; dynamisches Wert-Label in `onEntryArtChange`; abgesetzter Durchrechnen-Block.
- [x] **Step 1: Failing GUI-Test**
```python
def test_neuer_eintrag_formular_struktur(client, db):
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Scenario(name="UX-Test", description=""))
db.commit()
r = client.get("/szenarien").text
assert 'class="entry-form"' in r
assert 'class="entry-art"' in r # Eintragsart-Zeile
assert 'class="value-label"' in r # dynamisches Wert-Label
assert 'Für diese Eintragsart nicht relevant' in r # Tooltip an Umschaltfeldern
assert '<hr' in r # Durchrechnen abgesetzt
```
Run: → FAIL
- [x] **Step 2: CSS**`app/static/style.css` ergänzen:
```css
/* Ausbaustufe 7: Formular-Paare als Einheit, gesperrte Felder erkennbar. */
.entry-form label,
.planning-section form label {
display: inline-flex;
align-items: center;
gap: 0.4rem;
margin: 0 1.25rem 0.6rem 0;
white-space: nowrap;
}
.entry-form .entry-art {
display: block;
margin-bottom: 0.75rem;
}
.entry-form .entry-art > label { font-weight: bold; }
.entry-form .entry-art .muted { margin-left: 0.5rem; }
input:disabled, select:disabled {
background: #e3e3e3;
color: #8a8a8a;
cursor: not-allowed;
}
label:has(input:disabled), label:has(select:disabled) {
opacity: 0.55;
}
.project-block {
margin-top: 1rem;
}
```
(Falls `style.css` bereits eine kollidierende `label`-Regel für `.planning-section` hat: die neue Regel dahinter einsortieren, Spezifität prüfen.)
- [x] **Step 3: Formular-Markup** — in `szenarien.html` das Neuer-Eintrag-Formular umbauen:
- `<form … class="entry-form" …>` (Attribute inkl. `data-modifiers-url`/`data-planned-url` unverändert).
- Statt `<strong>Neuer Eintrag:</strong>`: erste Zeile
```html
<div class="entry-art">
<label>Eintragsart
<select name="kind" data-type="str" onchange="onEntryArtChange(this)">
{% for k in modifier_kinds %}<option value="{{ k }}">{{ k|de_label }}</option>{% endfor %}
<option value="einmal">Einmalzahlung</option>
</select>
</label>
<span class="muted">— die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut.</span>
</div>
```
- Wert-Label dynamisch: `<label><span class="value-label">Prozentsatz (%)</span> <input type="text" name="value" data-type="amount" value="0" title="Für diese Eintragsart nicht relevant"></label>` (Startzustand passend zur Default-Art `percent`).
- Die umschaltbaren Felder `end_date`, `name`, `amount`, `due` sowie die beiden Ziel-Selects bekommen `title="Für diese Eintragsart nicht relevant"`.
- Fieldset-Titel davor: `<h3>Einträge</h3>`-Tabelle bleibt; über dem Formular `<h4>Neuer Eintrag</h4>` (ersetzt das bisherige Inline-`<strong>`).
- Durchrechnen-Formular in einen abgesetzten Block:
```html
<hr>
<div class="project-block">
<form hx-post="/api/scenarios/{{ sc.id }}/project" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<button type="submit">Durchrechnen</button>
</form>
</div>
```
- [x] **Step 4: Dynamisches Wert-Label** — in `onEntryArtChange` (szenarien.html) nach der `setDisabled('value', …)`-Zeile ergänzen:
```javascript
var valueLabel = form.querySelector('.value-label');
if (valueLabel) {
valueLabel.textContent = art === 'percent' ? 'Prozentsatz (%)'
: art === 'absolute' ? 'Kürzung (€)' : 'Wert';
}
```
- [x] **Step 5: Suite grün**`.venv/bin/python -m pytest -q` → PASS
- [x] **Step 6: Fable-Testagent-Abnahme** — Live-Approximation + Handtrace: gerenderte Struktur (entry-art zuerst, Paare als `inline-flex`-Einheiten via CSS-Regeln vorhanden, Tooltips an allen Umschaltfeldern, `<hr>`+project-block), Wert-Label-Wechsel für alle 5 Arten, Startzustand konsistent (percent: Wert aktiv mit Label „Prozentsatz (%)"). Erst nach VERIFIED weiter.
- [x] **Step 7: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/static/style.css finance/app/templates/szenarien.html finance/tests/test_gui.py
git commit -m "feat: Eintrags-Formular mit Paar-Layout und Gesperrt-Kennzeichnung"
```
---
### Task 3: Kategorien-Verwaltung auf der Admin-Seite
**Files:**
- Modify: `app/routers/categories.py` (PATCH), `app/routers/admin.py` (Kontext), `app/templates/admin.html`
- Modify: `tests/test_crud_api.py`, `tests/test_admin.py`
**Interfaces:**
- Consumes: `CategoryIn`/`CategoryOut`, `POST /api/categories` (vorhanden); `toggleEdit` (seit Task 1 global in base.html); json-form.
- Produces: `PATCH /api/categories/{category_id}` (404 „Kategorie nicht gefunden", 409 „Kategorie existiert bereits"); Admin-Abschnitt „Kategorien" mit IDs `cat-row-{id}`/`cat-edit-{id}`.
- [x] **Step 1: Failing API-Test**`tests/test_crud_api.py`:
```python
def test_category_patch(client):
a = client.post("/api/categories", headers=H, json={"name": "Kat-A"}).json()
b = client.post("/api/categories", headers=H, json={"name": "Kat-B"}).json()
# Umbenennen
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200 and r.json()["name"] == "Kat-A-neu"
# Umbenennen auf den EIGENEN Namen ist erlaubt (kein 409)
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-A-neu"})
assert r.status_code == 200
# Kollision mit anderer Kategorie -> 409
r = client.patch(f"/api/categories/{a['id']}", headers=H, json={"name": "Kat-B"})
assert r.status_code == 409
# unbekannte id -> 404
assert client.patch("/api/categories/99999", headers=H,
json={"name": "x"}).status_code == 404
```
Run: `.venv/bin/python -m pytest tests/test_crud_api.py -q` → FAIL (405)
- [x] **Step 2: PATCH-Endpunkt**`app/routers/categories.py`, nach `create_category`:
```python
@router.patch("/categories/{category_id}", response_model=CategoryOut)
def patch_category(category_id: int, data: CategoryIn,
session: Session = Depends(get_session)):
cat = session.get(Category, category_id)
if cat is None:
raise HTTPException(404, "Kategorie nicht gefunden")
clash = session.execute(
select(Category).where(Category.name == data.name)).scalar()
if clash is not None and clash.id != category_id:
raise HTTPException(409, "Kategorie existiert bereits")
cat.name = data.name
session.commit()
session.refresh(cat)
return CategoryOut.model_validate(cat)
```
- [x] **Step 3: Failing GUI-Test**`tests/test_admin.py` (Muster für Login dort übernehmen; falls die Datei GUI-Zugriffe anders aufbaut, an bestehende Fixtures anlehnen):
```python
def test_admin_zeigt_kategorien_verwaltung(client, db):
from app.models.tables import Category
client.post("/login", data={"username": "admin", "password": "geheim"})
db.add(Category(name="Admin-Kat"))
db.commit()
r = client.get("/admin").text
assert "Kategorien" in r and "Admin-Kat" in r
assert "Neue Kategorie anlegen" in r
assert 'hx-patch="/api/categories/' in r
assert 'hx-post="/api/categories"' in r
```
Run: → FAIL
- [x] **Step 4: Admin-Kontext + Template**`app/routers/admin.py`: in `admin_page` (und im Fehler-/Erfolgs-Re-Render von `admin_change_password`, damit der Abschnitt nie verschwindet — UX-Regel) `"categories": session.execute(select(Category)).scalars().all()` in den Template-Kontext aufnehmen (Imports `select`, `Category` ergänzen; `session`-Dependency, falls die Route noch keine hat). `admin.html`, neuer Abschnitt vor dem `<script>`:
```html
<section class="admin-section">
<h2>Kategorien</h2>
<p class="muted">Kategorien für Buchungen, Fixposten und Szenario-Modifikatoren.
Löschen ist bewusst nicht vorgesehen (Kategorien hängen an Buchungen und Regeln).</p>
<table>
<thead><tr><th>Name</th><th></th></tr></thead>
<tbody>
{% for c in categories %}
<tr id="cat-row-{{ c.id }}">
<td>{{ c.name }}</td>
<td><button type="button" onclick="toggleEdit('cat', {{ c.id }}, true)">Bearbeiten</button></td>
</tr>
<tr id="cat-edit-{{ c.id }}" hidden>
<td colspan="2">
<form hx-ext="json-form" hx-patch="/api/categories/{{ c.id }}" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" value="{{ c.name }}" required maxlength="100"></label>
<button type="submit">Speichern</button>
<button type="button" onclick="toggleEdit('cat', {{ c.id }}, false)">Abbrechen</button>
</form>
</td>
</tr>
{% else %}
<tr><td colspan="2">Noch keine Kategorien.</td></tr>
{% endfor %}
</tbody>
</table>
<fieldset>
<legend>Neue Kategorie anlegen</legend>
<form hx-ext="json-form" hx-post="/api/categories" hx-swap="none"
hx-on::after-request="if(event.detail.successful){window.location.reload()}">
<label>Name <input type="text" name="name" required maxlength="100"></label>
<button type="submit">Anlegen</button>
</form>
</fieldset>
</section>
```
- [x] **Step 5: Suite grün**`.venv/bin/python -m pytest -q` → PASS
- [x] **Step 6: Fable-Testagent-Abnahme** (Live-Approximation: Anlegen → erscheint in Liste UND in den Kategorie-Dropdowns von Planung/Szenarien; Umbenennen-Roundtrip; 409 im Alert-Pfad; Admin-Fehler-Re-Render zeigt Abschnitt weiterhin). Erst nach VERIFIED weiter.
- [x] **Step 7: Commit**
```bash
cd /home/wlfb/bin && git add finance/app/routers/categories.py finance/app/routers/admin.py finance/app/templates/admin.html finance/tests/
git commit -m "feat: Kategorien-Verwaltung auf der Admin-Seite"
```
---
### Task 4: Release v0.8.0 — Redeploy, Live-Check, Ledger
**Files:** `finance/VERSION` (`0.7.0``0.8.0`), `.superpowers/sdd/progress.md`, Plan-Häkchen.
- [x] **Step 1: Suite final**`.venv/bin/python -m pytest -q` → alle grün.
- [x] **Step 2: Version + Commit**`echo "0.8.0" > finance/VERSION`, Commit `chore: Version 0.8.0`.
- [x] **Step 3: Redeploy**`./create_pod_finance.sh` (keine Migration). Service aktiv, Readiness 200.
- [x] **Step 4: Live-Smoke**`/api/version` == 0.8.0; Nav zeigt „Szenarien"; `/szenarien` zeigt Best Case mit Einträgen; `/planung` ohne Szenarien; `/admin` mit Kategorien-Abschnitt.
- [x] **Step 5: Fable-Testagent-Abnahme (Release-Gate)** — Live: Wegwerf-Kategorie „SMOKE-A7" anlegen → umbenennen → in Dropdowns sichtbar (bleibt stehen, Hinweis im Bericht — Kategorien sind nicht löschbar; Namenswahl „zz-Smoke" damit sie unten einsortiert? Nein: Kategorie „SMOKE-A7" wird nach dem Test per direktem psql-DELETE entfernt, NUR wenn keine FK-Referenzen existieren — vorher COUNT-Checks auf transactions/category_rules/recurring_items/scenario_modifiers); Best-Case-Einträge unverändert; Formular-Struktur auf /szenarien (entry-art, Tooltips, hr). Erst nach VERIFIED weiter.
- [x] **Step 6: Ledger + Plan-Häkchen + Push.**
---
## Self-Review (beim Planschreiben)
- **Spec-Abdeckung:** Seite → Task 1; UX/CSS/Label/Durchrechnen → Task 2; Kategorien (PATCH + Admin-GUI) → Task 3; Release → Task 4. Vollständig.
- **Platzhalter:** Der Template-Umzug in Task 1 Step 3 ist bewusst als präziser Move beschrieben (Quelle: bestehende Szenarien-Sektion) statt als 200-Zeilen-Duplikat — die Quelle ist eindeutig benannt und unverändert zu übernehmen.
- **Typ-Konsistenz:** `toggleEdit` global (Task 1) wird von Task 3 (`cat-`-Präfix) vorausgesetzt; `entry-form`/`entry-art`/`value-label`-Klassen konsistent zwischen CSS (Task 2 Step 2) und Markup (Step 3) und Test (Step 1).