diff --git a/docs/superpowers/specs/2026-09-28-ausbaustufe-11-design.md b/docs/superpowers/specs/2026-09-28-ausbaustufe-11-design.md new file mode 100644 index 0000000..1ab0e5e --- /dev/null +++ b/docs/superpowers/specs/2026-09-28-ausbaustufe-11-design.md @@ -0,0 +1,274 @@ +# 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 `` 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 (`

`) 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 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()`, + 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 + `