Files
bin/docs/superpowers/specs/2026-09-28-ausbaustufe-11-design.md
2026-09-28 18:33:59 +02:00

15 KiB
Raw Permalink Blame History

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 „System-Ist" (bewusst systemeigen benannt, Name für Szenarien reserviert), 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 'System-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.

Reservierter Name: Die Serienbezeichnung System-Ist ist als Modul-Konstante (RESERVED_SCENARIO_NAME in models/views.py, dort auch in der View-SQL verwendet) hinterlegt. routers/scenarios.py lehnt POST und PATCH mit diesem Namen (Vergleich nach strip().casefold()) mit 422 und der Meldung „Der Name »System-Ist« ist für die Ist-Kurve reserviert" ab, damit kein Szenario mit der Ist-Serie verschmelzen kann. Bestandsszenarien mit diesem Namen existieren nicht (live geprüft: 3 Szenarien, andere Namen). Tests: POST/PATCH mit System-Ist, system-ist, System-Ist → 422; normale Namen unverändert 201/200.

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.