Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
15 KiB
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 vonProjectionPoint,ProjectionResult,ScenarioLoan,ScenarioModifier,ScenarioPlannedItem(routers/scenarios.py). Es fehlt nur das GUI-Element.projection_pointssind Momentaufnahmen des letzten „Durchrechnen" (Serie abstart_date + 1bisstart_date + horizon_days,engine/projection.py::project). Wurde ein Szenario vor Wochen gerechnet, zeigtv_projectionfü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./buchungenist serverseitig paginiert (50/Seite, Standard-Reihenfolgebooking_date, idaufsteigend).
1. Szenario löschen
szenarien.html: im Szenario-Kopf (<p id="sc-row-…">) neben „Bearbeiten" ein Formularhx-delete="/api/scenarios/{{ sc.id }}" hx-swap="none",hx-confirm="Szenario „{{ sc.name }}“ mit allen Einträgen wirklich löschen?", bei Erfolgwindow.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):
- Ist-Serie:
SELECT 'System-Ist', day, balance FROM v_balance_total(reicht bisCURRENT_DATE). - Szenario-Startpunkt: je Szenario mit mindestens einem
Projektionspunkt eine Zeile
(s.name, CURRENT_DATE, <Ist-Saldo heute>)— Ist-Saldo ausv_balance_totalamCURRENT_DATE. Dadurch beginnt jede Szenario-Kurve lückenlos am Ist-Endpunkt. - Projektion:
projection_pointsmitday > 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
ProjectionResultundmin(ProjectionPoint.day) <= today(d.h. Rechen-Start lag vor heute). Maßstab ist der gespeicherte Projektionsbeginn, nichtcomputed_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.pybeim 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_atgleich). - Nie gerechnetes Szenario → bleibt ohne Result.
- GUI: GET
/szenarienlö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 überengine/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.namebzw."unkategorisiert"beicategory_id IS NULL(wiev_monthly_by_category). - Kreditraten: für alle
Loander Tilgungsplanengine/loans.py::loan_schedule(...); jede Rate mitwindow_start <= due <= window_endgeht mitInstallment.paymentunverändert in Kategorie"Kreditraten"ein —paymentist bereits negativ (-(interest + principal_part)), identisch zur Verwendung inbuild_cashflows. Die Auszahlung (payout_date,principal) zählt nicht (keine Rate). - Aggregation in Python mit
Decimalje (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.pybeim 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 mitend_datemitten 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
/planungbefü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/fieldConfigwie 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 (Commit224baba); 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
tablemitthead, außertable[data-sort-mode="server"]und bereits initialisierte Tabellen (Markierung per Attribut). - Jeder
thmit nicht-leerem Text bekommt einen<button type="button" class="sort-btn">hinter dem Text;thohne Text (Aktionsspalten) oder mitdata-nosortbekommen 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, sonsttextContent.trim(). Typerkennung in dieser Reihenfolge:- Datum
TT.MM.JJJJ→JJJJMMTT, - deutscher Betrag/Zahl (
-1.234,56, optional€/%) → Zahl, - sonst Text via
localeCompare(…, 'de'). Leere Werte und–stehen unabhängig von der Richtung immer am Ende. Stabile Sortierung (gleiche Werte behalten ihre Reihenfolge).
- Datum
- Zeilengruppen: eine Zeile mit
data-sort-followhängt an der vorhergehenden Zeile und wandert mit ihr (Bearbeiten-Zeilen*-edit-*inplanning.html,szenarien.htmlund ggf. weiteren Templates erhalten dieses Attribut). Platzhalterzeilen („Keine Einträge." mitcolspan) 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
tableintransactions.htmlerhältdata-sort-mode="server".routers/transactions.py::list_transactionsbekommt optionale Parametersort: 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 immerTransaction.id(gleiche Richtung). Standard (datum/asc) ergibt exakt die bisherige Reihenfolge.routers/gui.py::buchungenreichtsort/dirdurch 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,pageweglassen (→ 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/VERSION0.10.0 → 0.11.0, Redeploy via./create_pod_finance.sh(Migration läuft überentrypoint.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/buchungenund/planung, Bestandsdaten unverändert. docs/ARCHITEKTUR.mdumplanning_monthly,refresh_planning_chart,refresh_stale_projectionsundsort.jsergänzen;hilfe.htmlkurz 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.