diff --git a/docs/superpowers/plans/2026-07-20-ausbaustufe-7.md b/docs/superpowers/plans/2026-07-20-ausbaustufe-7.md new file mode 100644 index 0000000..0b0fba4 --- /dev/null +++ b/docs/superpowers/plans/2026-07-20-ausbaustufe-7.md @@ -0,0 +1,376 @@ +# 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 (`- [ ]`) 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-`
` 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. + +- [ ] **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) + +- [ ] **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"], + }) +``` + +- [ ] **Step 3: Template-Umzug** — Neues `app/templates/szenarien.html`: + +```html +{% extends "base.html" %} +{% block title %}Szenarien – Finanzberatung{% endblock %} +{% block content %} +

Szenarien

+ +
+ +
+ + +{% endblock %} +``` + +Der `

Szenarien

` entfällt (die Seite hat die `

`-Überschrift). In `planning.html`: die gesamte Szenarien-`
` 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`. + +- [ ] **Step 4: Nav + Hilfe** — `base.html`: `Szenarien` 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). + +- [ ] **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. + +- [ ] **Step 6: Suite grün** + +Run: `.venv/bin/python -m pytest -q` → PASS + +- [ ] **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. + +- [ ] **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. + +- [ ] **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 ' 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.) + +- [ ] **Step 3: Formular-Markup** — in `szenarien.html` das Neuer-Eintrag-Formular umbauen: + - `
` (Attribute inkl. `data-modifiers-url`/`data-planned-url` unverändert). + - Statt `Neuer Eintrag:`: erste Zeile + +```html +
+ + — die zur Art passenden Felder werden aktiv, gesperrte Felder sind ausgegraut. +
+``` + + - Wert-Label dynamisch: `` (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: `

Einträge

`-Tabelle bleibt; über dem Formular `

Neuer Eintrag

` (ersetzt das bisherige Inline-``). + - Durchrechnen-Formular in einen abgesetzten Block: + +```html +
+
+ + + +
+``` + +- [ ] **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'; + } +``` + +- [ ] **Step 5: Suite grün** — `.venv/bin/python -m pytest -q` → PASS + +- [ ] **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, `
`+project-block), Wert-Label-Wechsel für alle 5 Arten, Startzustand konsistent (percent: Wert aktiv mit Label „Prozentsatz (%)"). Erst nach VERIFIED weiter. + +- [ ] **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}`. + +- [ ] **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) + +- [ ] **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) +``` + +- [ ] **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 + +- [ ] **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 `