Files
bin/docs/superpowers/specs/2026-09-28-ausbaustufe-11-design.md
T

275 lines
14 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.
# Design — Ausbaustufe 11: Szenario-Löschen, Grafana-Korrekturen, Tabellensortierung (v0.11.0)
> Status: Design im Chat vom Nutzer freigegeben (2026-09-28), Spec zur
> Durchsicht. Anlass: vier Nutzerwünsche — (1) Szenarien lassen sich nicht
> löschen, (2) „Szenario-Vergleich" zeigt vor heute falsche Werte,
> (3) Planungs-Gegenstück zu „Monat nach Kategorie" fehlt, (4) Tabellen
> sollen per Spaltenkopf sortierbar sein.
## Nutzerentscheidungen (2026-09-28)
- Szenario-Vergleich vor heute: **Ist-Gesamtsaldo als eigene Serie „Ist"**,
Szenario-Kurven erst ab heute.
- Veraltete Szenarien: **automatisch neu rechnen** beim Seitenaufruf.
- Planungs-Diagramm: **Einnahmen + Ausgaben mit Vorzeichen** (Semantik wie
„Monat nach Kategorie").
- Kreditraten **aller** Kredite im Planungs-Diagramm als eigene Kategorie
**„Kreditraten"**, auch wenn keinem Szenario zugeordnet.
- Sortierung: **alle** Tabellen (Aufwand moderat, keine Einschränkung nötig).
## Befund (Ist-Zustand)
- `DELETE /api/scenarios/{id}` existiert bereits inkl. expliziter
Abräumung von `ProjectionPoint`, `ProjectionResult`, `ScenarioLoan`,
`ScenarioModifier`, `ScenarioPlannedItem` (`routers/scenarios.py`). Es
fehlt nur das GUI-Element.
- `projection_points` sind Momentaufnahmen des letzten „Durchrechnen"
(Serie ab `start_date + 1` bis `start_date + horizon_days`,
`engine/projection.py::project`). Wurde ein Szenario vor Wochen
gerechnet, zeigt `v_projection` für die Zeit bis heute eine alte Prognose
statt Ist-Werten; auch der Zukunftsteil startet vom damaligen Saldo.
- „Monat nach Kategorie" (Panel 3) liest `v_monthly_by_category` =
bestätigte Buchungen, also nur Vergangenheit.
- Tabellen: 14 statische `<table>` in 8 Templates (inkl. htmx-Fragmente
`_import_list.html`, `_preview_table.html`), plus der per JS erzeugte
Tilgungsplan. `/buchungen` ist serverseitig paginiert (50/Seite,
Standard-Reihenfolge `booking_date, id` aufsteigend).
## 1. Szenario löschen
- `szenarien.html`: im Szenario-Kopf (`<p id="sc-row-…">`) neben
„Bearbeiten" ein Formular
`hx-delete="/api/scenarios/{{ sc.id }}" hx-swap="none"`,
`hx-confirm="Szenario „{{ sc.name }}“ mit allen Einträgen wirklich löschen?"`,
bei Erfolg `window.location.reload()` — Muster wie die bestehenden
Einmalzahlungs-Löschbuttons.
- Keine API-Änderung.
- Tests: GUI-Seite enthält den Button mit korrektem `hx-delete`-Ziel;
(vorhandene API-Löschtests bleiben gültig, ggf. um GUI-Roundtrip
ergänzen: nach DELETE ist das Szenario nicht mehr auf `/szenarien`).
## 2. Szenario-Vergleich korrigieren
### 2a. View `v_projection` (`models/views.py`)
Neue Definition, Spalten unverändert (`scenario, day, balance`):
1. **Ist-Serie:** `SELECT 'Ist', day, balance FROM v_balance_total`
(reicht bis `CURRENT_DATE`).
2. **Szenario-Startpunkt:** je Szenario mit mindestens einem
Projektionspunkt eine Zeile `(s.name, CURRENT_DATE, <Ist-Saldo heute>)`
— Ist-Saldo aus `v_balance_total` am `CURRENT_DATE`. Dadurch beginnt
jede Szenario-Kurve lückenlos am Ist-Endpunkt.
3. **Projektion:** `projection_points` mit `day > CURRENT_DATE`.
Veraltete Punkte ≤ heute werden damit nie angezeigt — auch dann nicht,
wenn ein Szenario (noch) nicht neu gerechnet wurde.
Kombination per `UNION ALL`. `create_views` behandelt die geänderte
Definition bereits (CREATE OR REPLACE, Fallback DROP+CREATE). Da `v_projection`
auf `v_balance_total` aufbaut, muss es in `VIEWS` nach `v_balance_total`
stehen (ist bereits so; Reihenfolge beibehalten). Achtung: DROP … CASCADE
von `v_balance_total` im Fallback-Pfad würde `v_projection` mitreißen — da
`v_projection` danach ohnehin neu angelegt wird, ist das unkritisch.
Bekannte Grenze: ein Szenario namens „Ist" würde mit der Ist-Serie
verschmelzen. Wird hingenommen (keine Namensvalidierung eingeführt).
### 2b. Automatisches Neurechnen (`services/projection_service.py`)
Neue Funktion `refresh_stale_projections(session, today: date) -> int`
(Rückgabe: Anzahl neu gerechneter Szenarien):
- Kandidaten: Szenarien mit vorhandenem `ProjectionResult` **und**
`min(ProjectionPoint.day) <= today` (d.h. Rechen-Start lag vor heute).
Maßstab ist der gespeicherte Projektionsbeginn, nicht `computed_at`
(UTC-Zeitstempel, würde um Mitternacht falsch schneiden).
- Nie gerechnete Szenarien bleiben unberührt („Noch nicht durchgerechnet").
- Neu gerechnet mit `run_projection(session, scenario,
settings.horizon_days, today)` — identisch zum Button „Durchrechnen".
- Serialisierung: auf Postgres zu Beginn `SELECT pg_advisory_xact_lock(<konstante>)`,
danach Staleness neu prüfen (zwei parallele Seitenaufrufe dürfen nicht
doppelt Punkte schreiben). Auf SQLite (Tests) entfällt der Lock.
- Aufruf in `routers/gui.py` beim GET von `/` (Startseite, bettet Grafana
ein) und `/szenarien`, **vor** dem Rendern. Fehler im Refresh dürfen die
Seite nicht brechen: `try/except Exception` → `session.rollback()`,
Log-Warnung, Seite rendert mit dem vorhandenen Stand.
- „Durchrechnen" bleibt nötig nach Änderungen am selben Tag (ein heute
gerechnetes Szenario gilt nicht als veraltet).
Tests (SQLite, `run_projection` real):
- Szenario mit Start gestern → wird neu gerechnet, erster Punkt = heute+1.
- Szenario mit Start heute → unverändert (Zähler 0, `computed_at` gleich).
- Nie gerechnetes Szenario → bleibt ohne Result.
- GUI: GET `/szenarien` löst den Refresh aus; ein erzwungener Fehler im
Refresh ergibt trotzdem 200.
- View-Semantik (Postgres-only) wird im Fable-Gate in einer Wegwerf-DB
geprüft: Ist-Serie endet heute, Szenario-Serien beginnen heute mit dem
Ist-Saldo, keine Szenario-Punkte vor heute.
## 3. Neues Diagramm „Planung nach Kategorie"
### 3a. Tabelle `planning_monthly` (`models/tables.py` + Alembic-Migration)
| Spalte | Typ |
|---|---|
| `month` | `Date` (Monatserster), PK-Teil |
| `category` | `String(100)`, PK-Teil |
| `total` | `MONEY` (`Numeric(12,2)`) |
Composite Primary Key `(month, category)`. Migration mit up + down.
Abgeleitete Daten (jederzeit neu erzeugbar) — kein Backup-Sonderfall.
### 3b. Befüllung (`services/planning_chart.py`)
`refresh_planning_chart(session, today: date) -> None`:
- Fenster: `window_start` = 1. des laufenden Monats,
`window_end` = `today + settings.horizon_days`.
- **Wiederkehrende Posten** (`RecurringItem`): Termine über
`engine/recurrence.py::occurrences(rhythm, due_day, window_start,
window_end, start_date, end_date)` — dieselbe Logik wie Projektion und
„Nächste anstehende Posten". Betrag je Termin = `amount`.
- **Einmalposten** (`PlannedItem`): `window_start <= due <= window_end`.
- Kategorie: `Category.name` bzw. `"unkategorisiert"` bei
`category_id IS NULL` (wie `v_monthly_by_category`).
- **Kreditraten:** für **alle** `Loan` der Tilgungsplan
`engine/loans.py::loan_schedule(...)`; jede Rate mit
`window_start <= due <= window_end` geht mit `Installment.payment`
**unverändert** in Kategorie `"Kreditraten"` ein — `payment` ist bereits
negativ (`-(interest + principal_part)`), identisch zur Verwendung in
`build_cashflows`. Die Auszahlung (`payout_date`, `principal`) zählt
**nicht** (keine Rate).
- Aggregation in Python mit `Decimal` je (Monatserster, Kategorie);
Zeilen mit Summe 0 entfallen.
- Schreiben: gesamte Tabelle in **einer** Transaktion löschen und neu
befüllen; Serialisierung wie 2b (`pg_advisory_xact_lock`, eigene
Konstante).
- Aufruf in `routers/gui.py` beim GET von `/` und `/planung` (jede
Planung-Änderung lädt die Seite neu → Diagramm stets aktuell). Fehler
→ rollback + Log, Seite rendert trotzdem (wie 2b).
- Szenario-Modifikatoren fließen **nicht** ein (reine Planung).
Tests (Handrechnung, künstliche Werte — keine Echtdaten):
- monatlicher Posten mit Kategorie über Monatsgrenzen, quartalsweiser und
jährlicher Posten mit `start_date` (Phase), Posten mit `end_date`
mitten im Fenster, Posten ohne Kategorie → „unkategorisiert".
- Einmalposten innerhalb/außerhalb des Fensters.
- Kredit (annuity): Raten negativ unter „Kreditraten", Auszahlung fehlt.
- Zwei Aufrufe hintereinander → keine Duplikate (Idempotenz).
- GUI: GET `/planung` befüllt die Tabelle; Refresh-Fehler → trotzdem 200.
### 3c. Grafana-Panel (`grafana/dashboards/finanzen.json`)
- Neues Panel `id: 5`, `type: barchart`, Titel **„Planung nach Kategorie"**,
Optionen/`fieldConfig` wie Panel 3 (gestapelt, `currencyEUR`).
- Query: `SELECT month AS time, category AS metric, total AS value FROM
planning_monthly ORDER BY month`.
- Platzierung: direkt unter „Monat nach Kategorie": `gridPos
{h: 8, w: 12, x: 0, y: 16}`.
- Dashboard-Zeitfenster (`now-1y` … `now+19M`) deckt den Horizont bereits
ab (Commit 224baba); keine Änderung.
- Test: JSON-Strukturtest (Panel vorhanden, Query referenziert
`planning_monthly`, keine gridPos-Überlappung).
## 4. Tabellen sortieren
### 4a. Clientseitig: `app/static/sort.js`
Eingebunden in `base.html` (nach `htmx.min.js`). Initialisierung über
`htmx.onLoad(el => …)` — greift dadurch sowohl beim Seitenaufbau als auch
bei per htmx nachgeladenen Fragmenten (`_import_list.html`,
`_preview_table.html`).
- Erfasst werden alle `table` mit `thead`, außer `table[data-sort-mode="server"]`
und bereits initialisierte Tabellen (Markierung per Attribut).
- Jeder `th` mit nicht-leerem Text bekommt einen
`<button type="button" class="sort-btn">` hinter dem Text; `th` ohne Text
(Aktionsspalten) oder mit `data-nosort` bekommen keinen.
`aria-label="Nach <Spalte> sortieren"`.
- Symbol: `⇅` unsortiert, `▲` aufsteigend, `▼` absteigend. Klick auf eine
neue Spalte → aufsteigend; erneuter Klick → Richtung umkehren. Andere
Buttons der Tabelle springen auf `⇅` zurück. Buttons sind immer sichtbar
(UX-Regel).
- Sortierwert je Zelle: `td[data-sort]` falls vorhanden, sonst
`textContent.trim()`. Typerkennung in dieser Reihenfolge:
1. Datum `TT.MM.JJJJ` → `JJJJMMTT`,
2. deutscher Betrag/Zahl (`-1.234,56`, optional ` €`/` %`) → Zahl,
3. sonst Text via `localeCompare(…, 'de')`.
Leere Werte und `–` stehen unabhängig von der Richtung immer am Ende.
Stabile Sortierung (gleiche Werte behalten ihre Reihenfolge).
- **Zeilengruppen:** eine Zeile mit `data-sort-follow` hängt an der
vorhergehenden Zeile und wandert mit ihr (Bearbeiten-Zeilen
`*-edit-*` in `planning.html`, `szenarien.html` und ggf. weiteren
Templates erhalten dieses Attribut). Platzhalterzeilen („Keine
Einträge." mit `colspan`) werden nicht sortiert (Tabelle mit nur einer
Gruppe bleibt unverändert).
- Die Sortierung wird nicht gespeichert (nach Reload Standardreihenfolge).
Pflicht bei der Umsetzung: jede Spalte jeder Tabelle durchgehen und dort
`data-sort` setzen, wo die Anzeige nicht korrekt sortiert (z.B. Monatsspalte
`MM.JJJJ` in `salden.html` → `data-sort` ISO, übersetzte Labels, Zellen mit Formularen/Inputs, „Betrag
€" mit Zusatztext); Aktionsspalten mit Überschriftstext (z.B.
„Umbenennen", „Anker pflegen" in `index.html`) erhalten `data-nosort`.
Nicht erfasst: der per JS erzeugte Tilgungsplan in `planning.html`
(chronologisch per Definition; wird nach dem Erzeugen nicht initialisiert,
weil er ohne htmx eingefügt wird — so belassen).
Styling (`style.css`): `.sort-btn` dezent (kein Rahmen, kleine Schrift,
`cursor: pointer`), aktive Richtung hervorgehoben.
Tests: pytest prüft, dass `base.html` `sort.js` einbindet und die
Edit-Zeilen `data-sort-follow` tragen. Das JS-Verhalten (Typerkennung,
Gruppen, Leerwerte, Richtungswechsel) wird im Fable-Gate per Node
(`node` + minimaler DOM-Stub oder jsdom, falls vorhanden) bzw. im Browser
geprüft; wenn dafür eine reine Vergleichsfunktion aus `sort.js`
exportierbar ist, dort ansetzen.
### 4b. Serverseitig: `/buchungen`
- `table` in `transactions.html` erhält `data-sort-mode="server"`.
- `routers/transactions.py::list_transactions` bekommt optionale Parameter
`sort: str = "datum"`, `dir: str = "asc"`. Erlaubte Schlüssel (Whitelist,
unbekannte Werte → Standard, kein 422):
`datum` → `booking_date`, `konto` → `Account.name`, `zweck` → `purpose`,
`empfaenger` → `counterparty`, `betrag` → `amount`,
`kategorie` → `Category.name` (Outer-Join, unkategorisiert ans Ende).
Tiebreaker immer `Transaction.id` (gleiche Richtung). Standard
(`datum`/`asc`) ergibt exakt die bisherige Reihenfolge.
- `routers/gui.py::buchungen` reicht `sort`/`dir` durch und gibt sie ans
Template. Spaltenköpfe (Datum, Konto, Verwendungszweck,
Empfänger/Zahler, Betrag, Kategorie) werden Links mit demselben Symbol
wie 4a (`⇅/▲/▼`), die alle aktiven Filter übernehmen, `page` weglassen
(→ Seite 1) und bei aktiver Spalte die Richtung umkehren.
- Pager-Links übernehmen `sort`/`dir`.
- Tests: jede Sortierspalte auf/ab (inkl. Seitengrenze: Sortierung gilt
über alle Seiten), ungültige Werte → Standard ohne Fehler, Filter +
Sortierung kombiniert, Pager-Links enthalten `sort`/`dir`.
## 5. Release
- `finance/VERSION` 0.10.0 → 0.11.0, Redeploy via `./create_pod_finance.sh`
(Migration läuft über `entrypoint.sh` → `alembic upgrade head`).
- Live-Smoke: Szenario anlegen + löschen (Wegwerf-Szenario), veraltete
Live-Szenarien werden beim Aufruf von `/` automatisch neu gerechnet,
Grafana-Panels 4 und 5 zeigen plausible Daten, Sortierung auf
`/buchungen` und `/planung`, Bestandsdaten unverändert.
- `docs/ARCHITEKTUR.md` um `planning_monthly`, `refresh_planning_chart`,
`refresh_stale_projections` und `sort.js` ergänzen; `hilfe.html` kurz um
Sortierung/Löschen ergänzen, falls dort die Seiten beschrieben sind.
## Abgrenzung (nicht Teil dieser Stufe)
- Keine Speicherung der Sortierung, keine Mehrspalten-Sortierung.
- Kein Neurechnen bei jeder Datenänderung (nur Staleness-basiert + Button).
- Keine Kategorie-Farbabstimmung zwischen Panel 3 und 5 (Grafana-Default).
- Szenario-Modifikatoren nicht im Planungs-Diagramm.
## Rahmenregeln
- DATENSCHUTZ: Tests nur mit künstlichen Werten; keine Live-Beträge/Namen
in Commits, Ledger oder Chat.
- Decimal statt float für alle Beträge (auch in `planning_chart.py`).
- Deutsch für GUI-Texte, Commits, Fehlermeldungen; Datum TT.MM.JJJJ.
- Fable-Testagent-Gate je Plan-Task.