Files
bin/docs/superpowers/plans/2026-09-28-ausbaustufe-11.md
rdmsandClaude Opus 5.5 ffd7647c5f docs: A11 Plan-Haken gesetzt
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-28 19:44:35 +02:00

71 KiB
Raw Permalink Blame History

Ausbaustufe 11: Szenario-Löschen, Grafana-Korrekturen, Tabellensortierung — Implementation Plan

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 per GUI löschbar; „Szenario-Vergleich" zeigt vor heute nur den echten Ist-Saldo (Serie „System-Ist") und veraltete Szenarien werden automatisch neu gerechnet; neues Grafana-Panel „Planung nach Kategorie"; alle Tabellen per Spaltenkopf sortierbar (Buchungen serverseitig).

Architecture: (1) Reiner Template-Zusatz + Namens-Reservierung in der Szenario-API. (2) v_projection wird zur UNION aus Ist-Serie, Szenario- Startpunkt (heute) und Projektionspunkten nach heute; GUI-Seitenaufrufe rechnen veraltete Projektionen neu (refresh_stale_projections). (3) Neue abgeleitete Tabelle planning_monthly, bei GUI-Aufrufen komplett neu geschrieben aus RecurringItems, PlannedItems und Tilgungsplänen aller Kredite. (4) Ein generisches static/sort.js (htmx.onLoad) für alle Tabellen; /buchungen sortiert serverseitig über sort/dir.

Tech Stack: FastAPI, SQLAlchemy 2.0, Alembic, Jinja2, htmx, Postgres 17 (Prod) / SQLite (Tests), Grafana 12 (provisioniertes Dashboard), Vanilla-JS (ES5-kompatibel, keine Build-Kette).

Spec: docs/superpowers/specs/2026-09-28-ausbaustufe-11-design.md

Global Constraints

  • DATENSCHUTZ: tests/fixtures/* = echte Kontodaten, nie committen/zitieren. Tests nur mit künstlichen Werten; keine Live-Beträge/-Namen in Commits, Ledger, Chat.
  • Geld = decimal.Decimal (DB Numeric(12,2) = MONEY), keine float-Arithmetik für Beträge (Ausnahme: reine Sortier-Schlüssel im Browser-JS).
  • Deutsch für GUI-Texte, Fehlermeldungen, Kommentare, Commit-Messages; Datumsformat TT.MM.JJJJ.
  • UX-Regel: Bedienelemente sichtbar, Sperrzustände per disabled; Fehler inline bzw. über den bestehenden htmx:responseError-Alert.
  • Reservierter Serienname: System-Ist (Konstante RESERVED_SCENARIO_NAME in finance/app/models/views.py); API lehnt ihn für Szenarien mit 422 ab (Vergleich strip().casefold()), Meldung „Der Name »System-Ist« ist für die Ist-Kurve reserviert".
  • Test-Suite grün: cd finance && .venv/bin/python -m pytest -q — Basis 216 passed; jede Task erhöht die Zahl.
  • Fable-Testagent-Gate je Task vor dem Commit (CLAUDE.md). Ledger-Eintrag in .superpowers/sdd/progress.md pro Task (Commit-Range + Fable-Befund).
  • Code domain-neutral (nur fb.example.de in Code/Tests, falls überhaupt nötig).
  • Release am Ende als v0.11.0 (finance/VERSION), Redeploy via ./create_pod_finance.sh.
  • Wegwerf-Postgres für Postgres-only-Prüfungen: Image docker.io/library/postgres:17.10 (wie Pod), DB-Name MUSS auf _test enden (Test-Guard), niemals gegen finance_pod-DB.

Review Focus

  1. Leerwerte beim Sortieren (–, leere Zellen, z.B. Spalte „Start"/„Ende"/„Kategorie") müssen in beiden Richtungen am Ende stehen, nicht bei „absteigend" oben — Test in Task 4 (sort.test.js: „Leerwerte immer am Ende").
  2. Offene Bearbeiten-Zeile / Tilgungsplan-Zeile beim Sortieren: Zusatzzeilen (*-edit-*, Tilgungsplan-<details>-Zeile bei Krediten) müssen bei ihrer Hauptzeile bleiben, unabhängig davon ob gerade sichtbar — Tests in Task 4 (groupRows + DOM-Test + Template-Test auf data-sort-follow).
  3. Gesamt-Zeile in /salden darf beim Sortieren nicht zwischen die Konten rutschen — Task 4 verschiebt sie nach <tfoot> (Template-Test + DOM-Test „tfoot bleibt unberührt").
  4. Manipulierte/ungültige URL-Parameter sort/dir auf /buchungen dürfen weder 422 noch 500 liefern; Filter + Sortierung + Seitenwechsel müssen sich gegenseitig erhalten — Tests in Task 5.
  5. Szenario, das nie wieder neu gerechnet wird (Nutzer öffnet nur Grafana direkt): dessen alte Punkte ≤ heute dürfen trotzdem nicht erscheinen — Postgres-Test in Task 2 (test_views_pg.py, veraltete Punkte vor heute unsichtbar).

Task 1: Szenario löschen (GUI) + reservierter Name „System-Ist"

Files:

  • Modify: finance/app/models/views.py (Konstante oben einfügen)
  • Modify: finance/app/routers/scenarios.py (create_scenario, patch_scenario, neuer Helper)
  • Modify: finance/app/templates/szenarien.html (Löschen-Button im Szenario-Kopf)
  • Test: finance/tests/test_crud_api.py, finance/tests/test_gui.py

Interfaces:

  • Produces: app.models.views.RESERVED_SCENARIO_NAME: str = "System-Ist" (Task 2 verwendet sie in der View-SQL).

  • Step 1: Failing Tests schreiben

An finance/tests/test_crud_api.py anhängen (dort existiert bereits H = {"Authorization": "Bearer test-key"}):

import pytest


@pytest.mark.parametrize("name", ["System-Ist", "system-ist", "  System-Ist  ", "SYSTEM-IST"])
def test_szenario_name_system_ist_ist_reserviert_post(client, name):
    r = client.post("/api/scenarios", json={"name": name}, headers=H)
    assert r.status_code == 422
    assert "reserviert" in r.json()["detail"]


def test_szenario_name_system_ist_ist_reserviert_patch(client):
    sid = client.post("/api/scenarios", json={"name": "Normal"}, headers=H).json()["id"]
    r = client.patch(f"/api/scenarios/{sid}", json={"name": "System-Ist"}, headers=H)
    assert r.status_code == 422
    assert "reserviert" in r.json()["detail"]
    # Andere Felder ohne Namensänderung bleiben erlaubt.
    r = client.patch(f"/api/scenarios/{sid}", json={"description": "x"}, headers=H)
    assert r.status_code == 200


def test_szenario_normale_namen_weiter_erlaubt(client):
    assert client.post("/api/scenarios", json={"name": "Ist-Zustand plus"},
                       headers=H).status_code == 201

Hinweis: falls import pytest in der Datei schon vorhanden ist, nicht doppelt importieren.

An finance/tests/test_gui.py anhängen:

def test_szenarien_seite_hat_loeschen_button(client, db):
    client.post("/login", data={"username": "admin", "password": "geheim"})
    sc = Scenario(name="Wegwerf", description="", include_recurring=True,
                  include_planned=True)
    db.add(sc)
    db.commit()
    r = client.get("/szenarien")
    assert f'hx-delete="/api/scenarios/{sc.id}"' in r.text
    assert "Szenario „Wegwerf“ mit allen Einträgen wirklich löschen?" in r.text


def test_szenario_loeschen_roundtrip_gui(client, db):
    client.post("/login", data={"username": "admin", "password": "geheim"})
    sc = Scenario(name="Wegwerf-Roundtrip", description="", include_recurring=True,
                  include_planned=True)
    db.add(sc)
    db.commit()
    assert client.delete(f"/api/scenarios/{sc.id}", headers=H).status_code == 204
    assert "Wegwerf-Roundtrip" not in client.get("/szenarien").text
  • Step 2: Tests laufen lassen — müssen fehlschlagen

Run: cd finance && .venv/bin/python -m pytest -q tests/test_crud_api.py tests/test_gui.py -k "reserviert or loeschen or normale_namen" Expected: FAIL (POST liefert 201 statt 422; Button fehlt). test_szenario_loeschen_roundtrip_gui und test_szenario_normale_namen_weiter_erlaubt dürfen schon grün sein.

  • Step 3: Konstante in views.py

In finance/app/models/views.py direkt nach den Imports:

# Serienname der Ist-Kurve im Grafana-Panel „Szenario-Vergleich" (v_projection).
# Für Szenarien reserviert (routers/scenarios.py lehnt ihn ab), damit kein
# Szenario mit der Ist-Serie verschmelzen kann (Ausbaustufe 11).
RESERVED_SCENARIO_NAME = "System-Ist"
  • Step 4: Namensprüfung in routers/scenarios.py

Import ergänzen: from app.models.views import RESERVED_SCENARIO_NAME

Helper nach _get_scenario einfügen:

def _check_reserved_name(name: str | None) -> None:
    """Der Serienname der Ist-Kurve (v_projection) ist für Szenarien tabu."""
    if name is not None and (name.strip().casefold()
                             == RESERVED_SCENARIO_NAME.casefold()):
        raise HTTPException(
            422, f"Der Name »{RESERVED_SCENARIO_NAME}« ist für die Ist-Kurve reserviert")

In create_scenario als erste Zeile: _check_reserved_name(data.name). In patch_scenario direkt nach fields = data.model_dump(exclude_unset=True): _check_reserved_name(fields.get("name")).

  • Step 5: Löschen-Button in szenarien.html

Im <p id="sc-row-{{ sc.id }}"> direkt nach dem „Bearbeiten"-Button einfügen. Bewusst ein <button> mit htmx-Attributen, kein <form> — ein <form> innerhalb von <p> ist ungültiges HTML (der Parser schließt das <p> davor, der Button bliebe dann beim Bearbeiten sichtbar):

      <button type="button" hx-delete="/api/scenarios/{{ sc.id }}" hx-swap="none"
              hx-confirm="Szenario „{{ sc.name }}“ mit allen Einträgen wirklich löschen?"
              hx-on::after-request="if(event.detail.successful){window.location.reload()}">Löschen</button>
  • Step 6: Tests grün + Gesamtsuite

Run: cd finance && .venv/bin/python -m pytest -q Expected: alle grün, 216 + 8 = 224 passed.

  • Step 7: Fable-Gate — Faktencheck: Button im gerenderten HTML korrekt verschachtelt (nicht in <form>/außerhalb <p>), API-422 für alle Schreibvarianten, bestehende Löschlogik unverändert (Abräumung abhängiger Zeilen), Name-Konstante nur an einer Stelle definiert.

  • Step 8: Commit

git add finance/app/models/views.py finance/app/routers/scenarios.py \
        finance/app/templates/szenarien.html finance/tests/test_crud_api.py finance/tests/test_gui.py
git commit -m "feat: Szenario per GUI löschen, Name System-Ist reserviert (A11-T1)"

Task 2: Szenario-Vergleich — Ist-Serie + automatisches Neurechnen

Files:

  • Modify: finance/app/db.py (Helper advisory_xact_lock)
  • Modify: finance/app/models/views.py (v_projection)
  • Modify: finance/app/services/projection_service.py (refresh_stale_projections)
  • Modify: finance/app/routers/gui.py (_safe_refresh, Aufrufe in index, szenarien_page)
  • Create: finance/tests/test_projection_refresh.py
  • Create: finance/tests/test_views_pg.py (nur mit FB_TEST_PG_URL, sonst skip)
  • Test: finance/tests/test_gui.py

Interfaces:

  • Consumes: RESERVED_SCENARIO_NAME (Task 1).

  • Produces:

    • app.db.advisory_xact_lock(session: Session, key: int) -> None (Task 3 nutzt es).
    • app.services.projection_service.refresh_stale_projections(session: Session, today: date) -> int.
    • app.routers.gui._safe_refresh(session: Session, refresh: Callable[[Session, date], object], today: date) -> None (Task 3 nutzt es).
  • Step 1: Failing Service-Tests — finance/tests/test_projection_refresh.py:

from datetime import date, timedelta

from sqlalchemy import func, select

from app.config import get_settings
from app.models.tables import ProjectionPoint, ProjectionResult, Scenario
from app.services.projection_service import refresh_stale_projections, run_projection

TODAY = date(2026, 9, 28)


def _scenario(db, name):
    sc = Scenario(name=name, description="", include_recurring=False,
                  include_planned=False)
    db.add(sc)
    db.commit()
    return sc


def _first_day(db, sid):
    return db.execute(select(func.min(ProjectionPoint.day))
                      .where(ProjectionPoint.scenario_id == sid)).scalar()


def _count(db, sid):
    return db.execute(select(func.count()).select_from(ProjectionPoint)
                      .where(ProjectionPoint.scenario_id == sid)).scalar()


def test_veraltetes_szenario_wird_ab_heute_neu_gerechnet(db):
    sc = _scenario(db, "Alt")
    run_projection(db, sc, 10, TODAY - timedelta(days=5))
    assert _first_day(db, sc.id) == TODAY - timedelta(days=4)
    assert refresh_stale_projections(db, TODAY) == 1
    assert _first_day(db, sc.id) == TODAY + timedelta(days=1)
    assert _count(db, sc.id) == get_settings().horizon_days


def test_gestern_gerechnet_gilt_als_veraltet(db):
    sc = _scenario(db, "Gestern")
    run_projection(db, sc, 10, TODAY - timedelta(days=1))
    assert refresh_stale_projections(db, TODAY) == 1


def test_heute_gerechnet_bleibt_unveraendert(db):
    sc = _scenario(db, "Heute")
    run_projection(db, sc, 10, TODAY)
    before = db.get(ProjectionResult, sc.id).computed_at
    assert refresh_stale_projections(db, TODAY) == 0
    assert _count(db, sc.id) == 10
    assert db.get(ProjectionResult, sc.id).computed_at == before


def test_nie_gerechnetes_szenario_bleibt_ohne_ergebnis(db):
    sc = _scenario(db, "Nie")
    assert refresh_stale_projections(db, TODAY) == 0
    assert db.get(ProjectionResult, sc.id) is None
    assert _count(db, sc.id) == 0


def test_refresh_ist_idempotent(db):
    sc = _scenario(db, "Zweimal")
    run_projection(db, sc, 10, TODAY - timedelta(days=3))
    assert refresh_stale_projections(db, TODAY) == 1
    assert refresh_stale_projections(db, TODAY) == 0
    assert _count(db, sc.id) == get_settings().horizon_days
  • Step 2: Failing GUI-Tests + View-Strukturtest — an finance/tests/test_gui.py anhängen:
def test_szenarien_seite_rechnet_veraltete_projektion_neu(client, db):
    from sqlalchemy import func, select
    from app.models.tables import ProjectionPoint
    from app.services.projection_service import run_projection
    client.post("/login", data={"username": "admin", "password": "geheim"})
    sc = Scenario(name="Veraltet", description="", include_recurring=False,
                  include_planned=False)
    db.add(sc)
    db.commit()
    run_projection(db, sc, 10, date.today() - timedelta(days=3))
    assert client.get("/szenarien").status_code == 200
    first = db.execute(select(func.min(ProjectionPoint.day))
                       .where(ProjectionPoint.scenario_id == sc.id)).scalar()
    assert first == date.today() + timedelta(days=1)


def test_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
    def kaputt(session, today):
        raise RuntimeError("absichtlich kaputt")
    monkeypatch.setattr("app.routers.gui.refresh_stale_projections", kaputt)
    client.post("/login", data={"username": "admin", "password": "geheim"})
    for path in ("/", "/szenarien"):
        assert client.get(path).status_code == 200, path


def test_v_projection_zeigt_ist_serie_und_keine_alten_punkte():
    from app.models.views import RESERVED_SCENARIO_NAME, VIEWS
    body = VIEWS["v_projection"]
    assert f"'{RESERVED_SCENARIO_NAME}'" in body
    assert "p.day > CURRENT_DATE" in body
    names = list(VIEWS)
    assert names.index("v_balance_total") < names.index("v_projection")
  • Step 3: Postgres-View-Test anlegen — finance/tests/test_views_pg.py (läuft nur, wenn FB_TEST_PG_URL auf eine Wegwerf-DB mit Namen *_test zeigt; sonst skip):
"""Postgres-only-Prüfung der Grafana-Views (Ausbaustufe 11).

Läuft nur mit FB_TEST_PG_URL (Wegwerf-Postgres, DB-Name muss auf '_test'
enden - Schutz gegen versehentliche Ausführung gegen die Produktiv-DB).
Beispiel:
  podman run -d --rm --name a11-pg -e POSTGRES_PASSWORD=wegwerf \
    -e POSTGRES_DB=fb_test -p 127.0.0.1:55432:5432 docker.io/library/postgres:17.10
  FB_TEST_PG_URL=postgresql+psycopg://postgres:wegwerf@127.0.0.1:55432/fb_test \
    .venv/bin/python -m pytest -q tests/test_views_pg.py
"""
import os
from datetime import timedelta
from decimal import Decimal

import pytest
from sqlalchemy import create_engine, text
from sqlalchemy.engine import make_url
from sqlalchemy.orm import Session

from app.db import Base
import app.models  # noqa: F401
from app.models.tables import (Account, ProjectionPoint, ProjectionResult,
                               Scenario, Transaction)
from app.models.views import RESERVED_SCENARIO_NAME, VIEWS, create_views

PG_URL = os.environ.get("FB_TEST_PG_URL")
pytestmark = pytest.mark.skipif(not PG_URL, reason="FB_TEST_PG_URL nicht gesetzt")


def _reset(engine):
    with engine.begin() as conn:
        for name in VIEWS:
            conn.execute(text(f"DROP VIEW IF EXISTS {name} CASCADE"))
    Base.metadata.drop_all(engine)


@pytest.fixture
def pg():
    assert make_url(PG_URL).database.endswith("_test"), "nur Wegwerf-DB *_test!"
    engine = create_engine(PG_URL)
    _reset(engine)
    Base.metadata.create_all(engine)
    create_views(engine)
    with Session(engine) as session:
        yield session
    _reset(engine)
    engine.dispose()


def test_v_projection_ist_serie_und_szenario_ab_heute(pg):
    today = pg.execute(text("SELECT CURRENT_DATE")).scalar()
    acc = Account(bank="t", iban="DE-PG-1", name="Testkonto", type="giro",
                  anchor_date=today - timedelta(days=10),
                  anchor_balance=Decimal("1000.00"))
    pg.add(acc)
    pg.flush()
    pg.add(Transaction(account_id=acc.id, booking_date=today - timedelta(days=2),
                       amount=Decimal("-100.00"), purpose="t", counterparty="t",
                       status="confirmed", dedup_hash="pg-1"))
    stale = Scenario(name="Veraltet", description="")
    empty = Scenario(name="Ohne Punkte", description="")
    pg.add_all([stale, empty])
    pg.flush()
    # Veraltete Momentaufnahme: Punkte von heute-5 bis heute+5.
    for off in range(-5, 6):
        pg.add(ProjectionPoint(scenario_id=stale.id, day=today + timedelta(days=off),
                               balance=Decimal("5000.00")))
    pg.add(ProjectionResult(scenario_id=stale.id, computed_at=today,
                            low_point_date=today, low_point_balance=Decimal("0")))
    pg.commit()

    rows = pg.execute(text(
        "SELECT scenario, day, balance FROM v_projection ORDER BY scenario, day")).all()
    ist = [r for r in rows if r.scenario == RESERVED_SCENARIO_NAME]
    sc = [r for r in rows if r.scenario == "Veraltet"]
    assert max(r.day for r in ist) == today
    assert [r.balance for r in ist if r.day == today] == [Decimal("900.00")]
    # Szenario startet heute am Ist-Saldo, keine alten Punkte davor.
    assert sc[0].day == today and sc[0].balance == Decimal("900.00")
    assert all(r.day >= today for r in sc)
    assert [r.balance for r in sc[1:]] == [Decimal("5000.00")] * 5
    assert not any(r.scenario == "Ohne Punkte" for r in rows)
  • Step 4: Tests laufen lassen — müssen fehlschlagen

Run: cd finance && .venv/bin/python -m pytest -q tests/test_projection_refresh.py tests/test_gui.py tests/test_views_pg.py Expected: FAIL (ImportError refresh_stale_projections; View-Test: System-Ist fehlt); test_views_pg.py skipped.

  • Step 5: advisory_xact_lock in finance/app/db.py

Import from sqlalchemy import create_engine, text und anhängen:

def advisory_xact_lock(session: Session, key: int) -> None:
    """Serialisiert konkurrierende Schreiber abgeleiteter Daten (Projektionen,
    Planungs-Diagramm) über einen Postgres-Transaktions-Advisory-Lock. Der
    Lock endet mit dem nächsten commit/rollback. Auf SQLite (Tests) No-Op."""
    if session.get_bind().dialect.name == "postgresql":
        session.execute(text("SELECT pg_advisory_xact_lock(:k)"), {"k": key})
  • Step 6: refresh_stale_projections in projection_service.py

Imports ergänzen: from sqlalchemy import delete, func, select und from app.db import advisory_xact_lock. Anhängen:

# Advisory-Lock-Schlüssel (beliebig, aber projektweit eindeutig).
STALE_PROJECTION_LOCK = 110_001


def refresh_stale_projections(session: Session, today: date) -> int:
    """Rechnet jedes bereits durchgerechnete Szenario neu, dessen Projektion
    vor `today` beginnt (erster gespeicherter Punkt <= today, d.h. Rechen-Start
    < today). Maßstab ist der Projektionsbeginn, nicht computed_at (UTC-
    Zeitstempel, würde um Mitternacht falsch schneiden). Nie gerechnete
    Szenarien bleiben unberührt. Je Szenario: Lock nehmen, Staleness NEU
    prüfen (ein paralleler Seitenaufruf kann schon gerechnet haben), rechnen;
    run_projection committet und gibt damit den Lock frei.
    Rückgabe: Anzahl neu gerechneter Szenarien."""
    horizon = get_settings().horizon_days
    ids = list(session.execute(select(ProjectionResult.scenario_id)).scalars())
    refreshed = 0
    for sid in ids:
        advisory_xact_lock(session, STALE_PROJECTION_LOCK)
        first = session.execute(select(func.min(ProjectionPoint.day))
                                .where(ProjectionPoint.scenario_id == sid)).scalar()
        scenario = session.get(Scenario, sid)
        if scenario is None or first is None or first > today:
            session.commit()  # Lock freigeben
            continue
        run_projection(session, scenario, horizon, today)
        refreshed += 1
    return refreshed
  • Step 7: v_projection in views.py ersetzen
    # Szenario-Vergleich (Ausbaustufe 11): Ist-Gesamtsaldo bis heute als eigene
    # Serie RESERVED_SCENARIO_NAME; jede Szenario-Kurve beginnt heute am
    # Ist-Saldo und zeigt danach nur Projektionspunkte NACH heute. Punkte
    # <= heute einer älteren Berechnung (Momentaufnahme des letzten
    # „Durchrechnen") erscheinen damit nie - auch wenn ein Szenario noch nicht
    # neu gerechnet wurde. Muss in VIEWS NACH v_balance_total stehen.
    "v_projection": f"""
        SELECT '{RESERVED_SCENARIO_NAME}'::text AS scenario, t.day, t.balance
        FROM v_balance_total t
        UNION ALL
        SELECT s.name::text, t.day, t.balance
        FROM scenarios s
        JOIN v_balance_total t ON t.day = CURRENT_DATE
        WHERE EXISTS (SELECT 1 FROM projection_points p0
                      WHERE p0.scenario_id = s.id)
        UNION ALL
        SELECT s.name::text, p.day, p.balance
        FROM projection_points p JOIN scenarios s ON s.id = p.scenario_id
        WHERE p.day > CURRENT_DATE""",

Hinweis: die Spalte scenario wechselt von varchar(200) auf text → CREATE OR REPLACE schlägt fehl, create_views fällt automatisch auf DROP+CREATE zurück (vorhandenes Verhalten, kein Code nötig).

  • Step 8: GUI-Hooks in routers/gui.py

Imports: import logging, from typing import Callable, from app.services.projection_service import refresh_stale_projections. Nach router = APIRouter():

log = logging.getLogger(__name__)


def _safe_refresh(session: Session, refresh: Callable[[Session, date], object],
                  today: date) -> None:
    """Frischt abgeleitete Daten (Projektionen, Planungs-Diagramm) vor dem
    Rendern auf. Ein Fehler darf die Seite nie brechen: rollback, Warnung ins
    Log, die Seite rendert mit dem vorhandenen Stand."""
    try:
        refresh(session, today)
    except Exception:
        session.rollback()
        log.warning("Auffrischen fehlgeschlagen: %s",
                    getattr(refresh, "__name__", refresh), exc_info=True)

In index als erste Zeile und in szenarien_page als erste Zeile:

    _safe_refresh(session, refresh_stale_projections, date.today())

(Der Name refresh_stale_projections wird zur Laufzeit im Modul nachgeschlagen — deshalb greift monkeypatch.setattr("app.routers.gui.refresh_stale_projections", ...).)

  • Step 9: Tests grün + Gesamtsuite

Run: cd finance && .venv/bin/python -m pytest -q Expected: 224 + 8 = 232 passed, 1 skipped (test_views_pg.py).

  • Step 10: Fable-Gate — (a) Wegwerf-Postgres starten (Kommando im Docstring von test_views_pg.py), FB_TEST_PG_URL=... pytest tests/test_views_pg.py → passed; Container danach entfernen. (b) Race-Probe auf derselben Wegwerf-DB: zwei Threads rufen refresh_stale_projections gleichzeitig für ein veraltetes Szenario → danach genau horizon_days Punkte (keine Duplikate). (c) Code-Review Lock-Semantik (Lock endet mit commit in run_projection).

  • Step 11: Commit

git add finance/app/db.py finance/app/models/views.py finance/app/services/projection_service.py \
        finance/app/routers/gui.py finance/tests/test_projection_refresh.py \
        finance/tests/test_views_pg.py finance/tests/test_gui.py
git commit -m "fix: Szenario-Vergleich zeigt vor heute Ist-Saldo, veraltete Szenarien werden neu gerechnet (A11-T2)"

Task 3: Grafana-Panel „Planung nach Kategorie"

Files:

  • Modify: finance/app/models/tables.py (Klasse PlanningMonthly)
  • Create: finance/alembic/versions/d5e8b3c7a410_planning_monthly.py
  • Create: finance/app/services/planning_chart.py
  • Modify: finance/app/routers/gui.py (Aufrufe in index, planung_page)
  • Modify: finance/grafana/dashboards/finanzen.json (Panel 5)
  • Create: finance/tests/test_planning_chart.py
  • Test: finance/tests/test_grafana_dashboard.py, finance/tests/test_gui.py, finance/tests/test_views_pg.py

Interfaces:

  • Consumes: advisory_xact_lock, _safe_refresh (Task 2); occurrences (engine/recurrence.py), loan_schedule (engine/loans.py).

  • Produces:

    • app.models.tables.PlanningMonthly (Tabelle planning_monthly: month: date PK, category: str PK, total: Decimal).
    • app.services.planning_chart.planning_monthly_totals(session, today: date, horizon_days: int) -> dict[tuple[date, str], Decimal]
    • app.services.planning_chart.refresh_planning_chart(session, today: date) -> int (Anzahl geschriebener Zeilen).
  • Step 1: Failing Tests — finance/tests/test_planning_chart.py (Handrechnung; today=28.09.2026, horizon_days=100 → Fenster 01.09.2026 … 06.01.2027):

from datetime import date
from decimal import Decimal

from sqlalchemy import func, select

from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
                               RecurringItem)
from app.services.planning_chart import (KREDITRATEN, UNKATEGORISIERT,
                                         planning_monthly_totals,
                                         refresh_planning_chart)

TODAY = date(2026, 9, 28)
M = lambda y, m: date(y, m, 1)  # noqa: E731


def _setup(db):
    wohnen = Category(name="Wohnen")
    vers = Category(name="Versicherung")
    eink = Category(name="Einkommen")
    db.add_all([wohnen, vers, eink])
    db.flush()
    db.add_all([
        # monatlich, ohne Start -> 15.09., 15.10., 15.11., 15.12.
        RecurringItem(name="Miete", amount=Decimal("-50.00"), rhythm="monthly",
                      due_day=15, category_id=wohnen.id),
        # quartalsweise, Phase ab 01.08. -> nur 01.11. im Fenster
        RecurringItem(name="Police", amount=Decimal("-300.00"), rhythm="quarterly",
                      due_day=1, start_date=date(2026, 8, 1), category_id=vers.id),
        # jährlich ohne Kategorie, Phase Dezember -> 10.12.2026
        RecurringItem(name="Beitrag", amount=Decimal("-120.00"), rhythm="yearly",
                      due_day=10, start_date=date(2025, 12, 10)),
        # endet 31.10. -> nur 01.09. und 01.10.
        RecurringItem(name="Gehalt", amount=Decimal("1000.00"), rhythm="monthly",
                      due_day=1, end_date=date(2026, 10, 31), category_id=eink.id),
        # Einmalposten: innerhalb (Okt), vor dem Fenster, nach dem Fenster
        PlannedItem(name="Reparatur", amount=Decimal("-200.00"),
                    due=date(2026, 10, 5), category_id=wohnen.id),
        PlannedItem(name="Vorher", amount=Decimal("-999.00"), due=date(2026, 8, 31)),
        PlannedItem(name="Nachher", amount=Decimal("-999.00"), due=date(2027, 2, 1)),
        # Kredit 1.200 zu 0 % auf 12 Monate, Auszahlung 10.09. -> Raten je -100
        # am 10.10., 10.11., 10.12. (10.01.2027 liegt hinter dem Fenster).
        Loan(name="Auto", principal=Decimal("1200.00"), annual_rate_pct=Decimal("0"),
             term_months=12, payout_date=date(2026, 9, 10), repayment_type="annuity"),
    ])
    db.commit()


def test_planung_nach_kategorie_handrechnung(db):
    _setup(db)
    assert planning_monthly_totals(db, TODAY, 100) == {
        (M(2026, 9), "Wohnen"): Decimal("-50.00"),
        (M(2026, 10), "Wohnen"): Decimal("-250.00"),
        (M(2026, 11), "Wohnen"): Decimal("-50.00"),
        (M(2026, 12), "Wohnen"): Decimal("-50.00"),
        (M(2026, 11), "Versicherung"): Decimal("-300.00"),
        (M(2026, 12), UNKATEGORISIERT): Decimal("-120.00"),
        (M(2026, 9), "Einkommen"): Decimal("1000.00"),
        (M(2026, 10), "Einkommen"): Decimal("1000.00"),
        (M(2026, 10), KREDITRATEN): Decimal("-100.00"),
        (M(2026, 11), KREDITRATEN): Decimal("-100.00"),
        (M(2026, 12), KREDITRATEN): Decimal("-100.00"),
    }


def test_kredit_auszahlung_zaehlt_nicht(db):
    _setup(db)
    totals = planning_monthly_totals(db, TODAY, 100)
    assert all(v < 0 for (m, c), v in totals.items() if c == KREDITRATEN)
    assert (M(2026, 9), KREDITRATEN) not in totals


def test_nullsummen_entfallen(db):
    c = Category(name="Durchlauf")
    db.add(c)
    db.flush()
    db.add_all([
        PlannedItem(name="rein", amount=Decimal("10.00"), due=date(2026, 10, 1), category_id=c.id),
        PlannedItem(name="raus", amount=Decimal("-10.00"), due=date(2026, 10, 2), category_id=c.id),
    ])
    db.commit()
    assert planning_monthly_totals(db, TODAY, 100) == {}


def test_refresh_schreibt_tabelle_idempotent(db):
    _setup(db)
    n1 = refresh_planning_chart(db, TODAY)
    n2 = refresh_planning_chart(db, TODAY)
    assert n1 == n2 > 0
    assert db.execute(select(func.count()).select_from(PlanningMonthly)).scalar() == n2
    row = db.get(PlanningMonthly, (M(2026, 11), "Versicherung"))
    assert row.total == Decimal("-300.00")

Hinweis: refresh_planning_chart nutzt get_settings().horizon_days (Default 548) — deshalb prüft der letzte Test nur Idempotenz und eine eindeutig im Fenster liegende Zeile.

GUI + Dashboard, an finance/tests/test_gui.py anhängen:

def test_planung_seite_befuellt_planungs_diagramm(client, db):
    from app.models.tables import PlanningMonthly
    client.post("/login", data={"username": "admin", "password": "geheim"})
    db.add(PlannedItem(name="Kauf", amount=Decimal("-10.00"),
                       due=date.today() + timedelta(days=3)))
    db.commit()
    assert client.get("/planung").status_code == 200
    assert db.query(PlanningMonthly).count() == 1


def test_planungs_refresh_fehler_bricht_seiten_nicht(client, monkeypatch):
    def kaputt(session, today):
        raise RuntimeError("absichtlich kaputt")
    monkeypatch.setattr("app.routers.gui.refresh_planning_chart", kaputt)
    client.post("/login", data={"username": "admin", "password": "geheim"})
    for path in ("/", "/planung"):
        assert client.get(path).status_code == 200, path

An finance/tests/test_grafana_dashboard.py anhängen:

def test_panel_planung_nach_kategorie():
    panels = {p["title"]: p for p in _load()["panels"]}
    p = panels["Planung nach Kategorie"]
    assert p["type"] == "barchart"
    assert "planning_monthly" in p["targets"][0]["rawSql"]
    assert p["options"]["stacking"] == "normal"


def test_panels_ueberlappen_nicht_und_ids_eindeutig():
    panels = _load()["panels"]
    assert len({p["id"] for p in panels}) == len(panels)
    cells = set()
    for p in panels:
        g = p["gridPos"]
        for x in range(g["x"], g["x"] + g["w"]):
            for y in range(g["y"], g["y"] + g["h"]):
                assert (x, y) not in cells, p["title"]
                cells.add((x, y))

An finance/tests/test_views_pg.py anhängen (Postgres-Idempotenz inkl. Lock-Pfad):

def test_planning_chart_refresh_auf_postgres(pg):
    from app.models.tables import PlannedItem, PlanningMonthly
    from app.services.planning_chart import refresh_planning_chart
    today = pg.execute(text("SELECT CURRENT_DATE")).scalar()
    pg.add(PlannedItem(name="t", amount=Decimal("-5.00"), due=today + timedelta(days=2)))
    pg.commit()
    assert refresh_planning_chart(pg, today) == 1
    assert refresh_planning_chart(pg, today) == 1
    assert pg.query(PlanningMonthly).count() == 1
  • Step 2: Tests laufen lassen — müssen fehlschlagen

Run: cd finance && .venv/bin/python -m pytest -q tests/test_planning_chart.py tests/test_gui.py tests/test_grafana_dashboard.py Expected: FAIL (ImportError PlanningMonthly/planning_chart, Panel fehlt).

  • Step 3: Modell — an finance/app/models/tables.py anhängen:
class PlanningMonthly(Base):
    """Abgeleitete Daten für das Grafana-Panel „Planung nach Kategorie"
    (Ausbaustufe 11): Monatssummen je Kategorie aus der Planung. Wird von
    services/planning_chart.refresh_planning_chart komplett neu geschrieben;
    jederzeit reproduzierbar, kein Backup-Sonderfall."""
    __tablename__ = "planning_monthly"
    month: Mapped[date] = mapped_column(Date, primary_key=True)
    category: Mapped[str] = mapped_column(String(100), primary_key=True)
    total: Mapped[Decimal] = mapped_column(MONEY)
  • Step 4: Migration — finance/alembic/versions/d5e8b3c7a410_planning_monthly.py:
"""planning_monthly (Grafana-Panel Planung nach Kategorie)

Revision ID: d5e8b3c7a410
Revises: c4d7e2a91b53
Create Date: 2026-09-28

"""
from typing import Sequence, Union

from alembic import op
import sqlalchemy as sa


revision: str = 'd5e8b3c7a410'
down_revision: Union[str, Sequence[str], None] = 'c4d7e2a91b53'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None


def upgrade() -> None:
    op.create_table(
        'planning_monthly',
        sa.Column('month', sa.Date(), primary_key=True),
        sa.Column('category', sa.String(length=100), primary_key=True),
        sa.Column('total', sa.Numeric(precision=12, scale=2), nullable=False),
    )


def downgrade() -> None:
    op.drop_table('planning_monthly')
  • Step 5: Service — finance/app/services/planning_chart.py:
"""Planungs-Diagramm (Grafana-Panel „Planung nach Kategorie", Ausbaustufe 11).

Monatssummen je Kategorie aus der reinen Planung (ohne Szenario-Modifikatoren):
wiederkehrende Posten, Einmalposten und die Raten ALLER Kredite (Kategorie
„Kreditraten", Auszahlung zählt nicht). Einnahmen und Ausgaben mit Vorzeichen,
Semantik wie v_monthly_by_category. Kreditraten brauchen die Annuitätenformel
(engine/loans.py) - daher Python + Tabelle statt SQL-View.
"""
from collections import defaultdict
from datetime import date, timedelta
from decimal import Decimal

from sqlalchemy import delete, select
from sqlalchemy.orm import Session

from app.config import get_settings
from app.db import advisory_xact_lock
from app.engine.loans import loan_schedule
from app.engine.recurrence import occurrences
from app.models.tables import (Category, Loan, PlannedItem, PlanningMonthly,
                               RecurringItem)

UNKATEGORISIERT = "unkategorisiert"
KREDITRATEN = "Kreditraten"
PLANNING_CHART_LOCK = 110_002


def planning_monthly_totals(session: Session, today: date,
                            horizon_days: int) -> dict[tuple[date, str], Decimal]:
    """Fenster: 1. des laufenden Monats bis today + horizon_days. Schlüssel
    (Monatserster, Kategoriename); Summen 0 entfallen."""
    window_start = today.replace(day=1)
    window_end = today + timedelta(days=horizon_days)
    names = {c.id: c.name for c in session.execute(select(Category)).scalars()}
    totals: dict[tuple[date, str], Decimal] = defaultdict(lambda: Decimal("0"))

    def category(category_id: int | None) -> str:
        return names.get(category_id, UNKATEGORISIERT) if category_id else UNKATEGORISIERT

    for r in session.execute(select(RecurringItem)).scalars():
        for d in occurrences(r.rhythm, r.due_day, window_start, window_end,
                             r.start_date, r.end_date):
            totals[(d.replace(day=1), category(r.category_id))] += Decimal(r.amount)
    for p in session.execute(select(PlannedItem)).scalars():
        if window_start <= p.due <= window_end:
            totals[(p.due.replace(day=1), category(p.category_id))] += Decimal(p.amount)
    for loan in session.execute(select(Loan)).scalars():
        for inst in loan_schedule(Decimal(loan.principal), Decimal(loan.annual_rate_pct),
                                  loan.term_months, loan.payout_date,
                                  loan.repayment_type):
            if window_start <= inst.due <= window_end:
                # payment ist bereits negativ (Abfluss), wie in build_cashflows.
                totals[(inst.due.replace(day=1), KREDITRATEN)] += inst.payment
    return {k: v for k, v in totals.items() if v != 0}


def refresh_planning_chart(session: Session, today: date) -> int:
    """Schreibt planning_monthly in EINER Transaktion komplett neu (Lock
    gegen parallele Seitenaufrufe). Rückgabe: Anzahl geschriebener Zeilen."""
    advisory_xact_lock(session, PLANNING_CHART_LOCK)
    totals = planning_monthly_totals(session, today, get_settings().horizon_days)
    session.execute(delete(PlanningMonthly))
    session.add_all(PlanningMonthly(month=m, category=c, total=v)
                    for (m, c), v in sorted(totals.items()))
    session.commit()
    return len(totals)
  • Step 6: GUI-Hooks — in routers/gui.py Import from app.services.planning_chart import refresh_planning_chart. In index (nach dem Projektions-Refresh aus Task 2) und als erste Zeile in planung_page:
    _safe_refresh(session, refresh_planning_chart, date.today())
  • Step 7: Grafana-Panel — in finance/grafana/dashboards/finanzen.json nach Panel 4 als fünftes Element von panels einfügen (2 Leerzeichen Einrückung wie bestehend):
    {
      "id": 5,
      "type": "barchart",
      "title": "Planung nach Kategorie",
      "datasource": { "type": "postgres", "uid": "FinanzDB" },
      "gridPos": { "h": 8, "w": 12, "x": 0, "y": 16 },
      "fieldConfig": { "defaults": { "unit": "currencyEUR" }, "overrides": [] },
      "options": {
        "legend": { "displayMode": "list", "placement": "bottom" },
        "stacking": "normal",
        "xTickLabelRotation": 0
      },
      "targets": [
        {
          "refId": "A",
          "datasource": { "type": "postgres", "uid": "FinanzDB" },
          "format": "time_series",
          "rawSql": "SELECT month AS time, category AS metric, total AS value FROM planning_monthly ORDER BY month",
          "rawQuery": true
        }
      ]
    }

Danach python3 -m json.tool finance/grafana/dashboards/finanzen.json >/dev/null (Syntax-Check).

  • Step 8: Tests grün + Gesamtsuite

Run: cd finance && .venv/bin/python -m pytest -q Expected: 232 + 8 = 240 passed, 2 skipped (test_views_pg.py).

  • Step 9: Fable-Gate — (a) Wegwerf-Postgres: alembic upgrade head → alembic downgrade -1 → alembic upgrade head gegen FB_DATABASE_URL der Wegwerf-DB; Tabelle/PK per \d planning_monthly prüfen. (b) FB_TEST_PG_URL=... pytest tests/test_views_pg.py → 2 passed. (c) Unabhängige Handrechnung eines zweiten Szenarios (z.B. endfälliger Kredit bullet mit Zins) gegen planning_monthly_totals. (d) Datenschutz-Grep: keine Echtdaten in neuen Tests.

  • Step 10: Commit

git add finance/app/models/tables.py finance/alembic/versions/d5e8b3c7a410_planning_monthly.py \
        finance/app/services/planning_chart.py finance/app/routers/gui.py \
        finance/grafana/dashboards/finanzen.json finance/tests/test_planning_chart.py \
        finance/tests/test_gui.py finance/tests/test_grafana_dashboard.py finance/tests/test_views_pg.py
git commit -m "feat: Grafana-Panel Planung nach Kategorie inkl. Kreditraten (A11-T3)"

Task 4: Clientseitige Tabellensortierung (sort.js)

Files:

  • Create: finance/app/static/sort.js
  • Create: finance/tests/js/sort.test.js (Unit, node --test)
  • Create: finance/tests/js/sort.dom.test.js (DOM, braucht jsdom, sonst skip)
  • Create: finance/tests/test_sort_js.py (pytest-Wrapper, skip ohne node)
  • Create: finance/tests/test_sort_templates.py
  • Modify: finance/app/templates/base.html (Script einbinden)
  • Modify: finance/app/static/style.css (.sort-btn)
  • Modify: finance/app/templates/planning.html, szenarien.html, admin.html, index.html, salden.html, _import_list.html, transactions.html (Attribute, s.u.)

Interfaces:

  • Produces (JS, window.FBSort bzw. module.exports): sortKey(raw: string, fromDataSort: bool) -> {kind:'date'|'number'|'text', value, text} | null, columnKind(keys) -> 'date'|'number'|'text', sortItems(items: [{key, index, ...}], dir: 'asc'|'desc') -> items, groupRows(rows, isFollow) -> rows[][], sortTable(table, colIndex, dir), initTable(table), init(root).

  • Template-Konventionen (Task 5 nutzt data-sort-mode="server"): tr[data-sort-follow] hängt an der Vorzeile; td[data-sort] überschreibt den Sortierwert (Zahlen Punkt-dezimal, Datum ISO); th[data-nosort] bekommt keinen Button; table[data-sort-mode="server"] wird übersprungen.

  • Step 1: Failing JS-Unit-Tests — finance/tests/js/sort.test.js:

'use strict';
const test = require('node:test');
const assert = require('node:assert');
const S = require('../../app/static/sort.js');

const items = (vals, ds) => vals.map((v, i) => ({ key: S.sortKey(v, !!ds), index: i, label: v }));
const labels = (arr) => arr.map((i) => i.label);

test('Datum TT.MM.JJJJ', () => {
  const k = S.sortKey('05.03.2026', false);
  assert.equal(k.kind, 'date');
  assert.equal(k.value, 20260305);
});

test('deutsche Beträge und Zahlen', () => {
  assert.equal(S.sortKey('-1.234,56 €', false).value, -1234.56);
  assert.equal(S.sortKey('5,00 %', false).value, 5);
  assert.equal(S.sortKey('15', false).kind, 'number');
  assert.equal(S.sortKey('1.234', false).value, 1234);
});

test('data-sort: Punkt-Dezimal und ISO-Datum', () => {
  assert.equal(S.sortKey('-1234.56', true).value, -1234.56);
  assert.equal(S.sortKey('2026-06-30', true).kind, 'text');
});

test('Leerwerte sind null', () => {
  assert.equal(S.sortKey('', false), null);
  assert.equal(S.sortKey('  ', false), null);
  assert.equal(S.sortKey('–', false), null);
  assert.equal(S.sortKey(undefined, false), null);
});

test('Zahlen numerisch statt alphabetisch', () => {
  assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'asc')),
                   ['-100,00', '9,00', '10,00']);
  assert.deepEqual(labels(S.sortItems(items(['10,00', '9,00', '-100,00']), 'desc')),
                   ['10,00', '9,00', '-100,00']);
});

test('Datum chronologisch', () => {
  assert.deepEqual(labels(S.sortItems(items(['01.02.2026', '15.01.2026', '31.12.2025']), 'asc')),
                   ['31.12.2025', '15.01.2026', '01.02.2026']);
});

test('Leerwerte immer am Ende, beide Richtungen', () => {
  const v = ['01.02.2026', '–', '15.01.2026', ''];
  assert.deepEqual(labels(S.sortItems(items(v), 'asc')), ['15.01.2026', '01.02.2026', '–', '']);
  assert.deepEqual(labels(S.sortItems(items(v), 'desc')), ['01.02.2026', '15.01.2026', '–', '']);
});

test('gemischte Typen -> Text', () => {
  assert.equal(S.columnKind([S.sortKey('10,00'), S.sortKey('abc'), null]), 'text');
  assert.equal(S.columnKind([S.sortKey('10,00'), null]), 'number');
});

test('Text deutsch (Umlaute, Groß/klein)', () => {
  assert.deepEqual(labels(S.sortItems(items(['Zahnarzt', 'Ärztin', 'miete']), 'asc')),
                   ['Ärztin', 'miete', 'Zahnarzt']);
});

test('stabil bei gleichen Werten', () => {
  const it = [{ key: S.sortKey('5'), index: 0, label: 'a' }, { key: S.sortKey('5'), index: 1, label: 'b' }];
  assert.deepEqual(labels(S.sortItems(it, 'desc')), ['a', 'b']);
});

test('groupRows: Folgezeilen hängen an der Vorzeile', () => {
  const rows = [{ id: 'a' }, { id: 'a-edit', f: true }, { id: 'a-plan', f: true },
                { id: 'b' }, { id: 'b-edit', f: true }];
  assert.deepEqual(S.groupRows(rows, (r) => !!r.f).map((g) => g.map((r) => r.id)),
                   [['a', 'a-edit', 'a-plan'], ['b', 'b-edit']]);
});
  • Step 2: DOM-Test (jsdom, optional) — finance/tests/js/sort.dom.test.js:
'use strict';
const test = require('node:test');
const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');

let JSDOM = null;
try { ({ JSDOM } = require('jsdom')); } catch (e) { /* jsdom fehlt -> skip */ }
const SRC = fs.readFileSync(path.join(__dirname, '../../app/static/sort.js'), 'utf8');

function load(html) {
  const dom = new JSDOM(`<!doctype html><body>${html}</body>`, { runScripts: 'outside-only' });
  dom.window.eval(SRC);
  dom.window.FBSort.init(dom.window.document.body);
  return dom.window.document;
}

const TABLE = `
<table id="t"><thead><tr><th>Name</th><th>Betrag</th><th>Aktion</th><th></th></tr></thead>
<tbody>
  <tr id="r1"><td>B</td><td>10,00 €</td><td>x</td><td></td></tr>
  <tr id="r1-edit" hidden data-sort-follow><td colspan="4">edit1</td></tr>
  <tr id="r2"><td>A</td><td>-5,00 €</td><td>x</td><td></td></tr>
  <tr id="r2-edit" hidden data-sort-follow><td colspan="4">edit2</td></tr>
  <tr id="r3"><td>C</td><td>–</td><td>x</td><td></td></tr>
</tbody>
<tfoot><tr id="sum"><td>Gesamt</td><td>5,00 €</td><td></td><td></td></tr></tfoot>
</table>
<table data-sort-mode="server" id="s"><thead><tr><th>Datum</th></tr></thead><tbody></tbody></table>`;

const ids = (doc) => [...doc.querySelectorAll('#t tbody tr')].map((r) => r.id);

test('Buttons nur an Spalten mit Text, nicht an server-Tabellen', { skip: !JSDOM }, () => {
  const doc = load(TABLE.replace('<th>Aktion</th>', '<th data-nosort>Aktion</th>'));
  assert.equal(doc.querySelectorAll('#t .sort-btn').length, 2);
  assert.equal(doc.querySelectorAll('#s .sort-btn').length, 0);
  assert.equal(doc.querySelector('#t .sort-btn').textContent, '⇅');
});

test('Klick sortiert auf/ab, Folgezeilen wandern mit, Leerwert am Ende, tfoot bleibt', { skip: !JSDOM }, () => {
  const doc = load(TABLE);
  const btn = doc.querySelectorAll('#t .sort-btn')[1]; // Betrag
  btn.click();
  assert.equal(btn.textContent, '▲');
  assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
  btn.click();
  assert.equal(btn.textContent, '▼');
  assert.deepEqual(ids(doc), ['r1', 'r1-edit', 'r2', 'r2-edit', 'r3']);
  assert.equal(doc.querySelector('#t tfoot tr').id, 'sum');
  const nameBtn = doc.querySelectorAll('#t .sort-btn')[0];
  nameBtn.click();
  assert.equal(btn.textContent, '⇅');
  assert.deepEqual(ids(doc), ['r2', 'r2-edit', 'r1', 'r1-edit', 'r3']);
});

test('init zweimal erzeugt keine doppelten Buttons', { skip: !JSDOM }, () => {
  const doc = load(TABLE);
  doc.defaultView.FBSort.init(doc.body);
  assert.equal(doc.querySelectorAll('#t .sort-btn').length, 3);
});
  • Step 3: pytest-Wrapper — finance/tests/test_sort_js.py:
"""Führt die JS-Tests von static/sort.js aus, falls `node` installiert ist.
Auf dem Host fehlt node derzeit - dann skip; das Fable-Gate führt die Tests
per Podman aus (siehe Plan A11 Task 4, Fable-Gate)."""
import shutil
import subprocess
from pathlib import Path

import pytest

ROOT = Path(__file__).resolve().parent.parent
NODE = shutil.which("node")


@pytest.mark.skipif(NODE is None, reason="node nicht installiert")
def test_sort_js():
    r = subprocess.run([NODE, "--test", str(ROOT / "tests" / "js")],
                       capture_output=True, text=True)
    assert r.returncode == 0, r.stdout + r.stderr
  • Step 4: Failing Template-Tests — finance/tests/test_sort_templates.py:
import re
from datetime import date
from decimal import Decimal

from app.models.tables import (Account, Category, Loan, PlannedItem,
                               RecurringItem, Scenario, ScenarioModifier,
                               ScenarioPlannedItem)


def _login(client):
    client.post("/login", data={"username": "admin", "password": "geheim"})


def _follow_ids(html):
    return re.findall(r'<tr id="([a-z]+-(?:edit|plan)-\d+)"[^>]*data-sort-follow', html)


def test_base_bindet_sort_js_ein(client):
    _login(client)
    assert '<script src="/static/sort.js"></script>' in client.get("/").text


def test_planung_folgezeilen_markiert(client, db):
    _login(client)
    c = Category(name="K")
    db.add(c)
    db.flush()
    db.add_all([
        RecurringItem(name="R", amount=Decimal("-1.00"), rhythm="monthly", due_day=1, category_id=c.id),
        PlannedItem(name="P", amount=Decimal("-1.00"), due=date(2030, 1, 1)),
        Loan(name="L", principal=Decimal("100.00"), annual_rate_pct=Decimal("1"),
             term_months=2, payout_date=date(2030, 1, 1), repayment_type="annuity"),
    ])
    db.commit()
    kinds = sorted(i.split("-")[0] + "-" + i.split("-")[1] for i in _follow_ids(client.get("/planung").text))
    assert kinds == ["loan-edit", "loan-plan", "pln-edit", "rec-edit"]


def test_szenarien_und_admin_folgezeilen_markiert(client, db):
    _login(client)
    c = Category(name="K")
    sc = Scenario(name="S", description="")
    db.add_all([c, sc])
    db.flush()
    db.add_all([
        ScenarioModifier(scenario_id=sc.id, target_type="category", target_id=c.id,
                         kind="percent", value=Decimal("10")),
        ScenarioPlannedItem(scenario_id=sc.id, name="E", amount=Decimal("-1.00"), due=date(2030, 1, 1)),
    ])
    db.commit()
    assert sorted(i.rsplit("-", 1)[0] for i in _follow_ids(client.get("/szenarien").text)) == ["mod-edit", "spi-edit"]
    assert [i.rsplit("-", 1)[0] for i in _follow_ids(client.get("/admin").text)] == ["cat-edit"]


def test_salden_gesamtzeile_im_tfoot(client, db):
    _login(client)
    db.add(Account(bank="t", iban="DE-S-1", name="Konto", type="giro"))
    db.commit()
    html = client.get("/salden").text
    assert re.search(r'<tfoot>\s*<tr class="total-row">', html)


def test_index_aktionsspalten_ohne_sortierung(client):
    _login(client)
    html = client.get("/").text
    assert "<th data-nosort>Umbenennen</th>" in html
    assert "<th data-nosort>Anker pflegen</th>" in html


def test_buchungen_tabelle_ist_serverseitig(client):
    _login(client)
    assert 'data-sort-mode="server"' in client.get("/buchungen").text
  • Step 5: Tests laufen lassen — müssen fehlschlagen

Run: cd finance && .venv/bin/python -m pytest -q tests/test_sort_templates.py tests/test_sort_js.py Expected: FAIL in test_sort_templates.py; test_sort_js.py skipped (kein node).

  • Step 6: finance/app/static/sort.js implementieren
/* Tabellensortierung per Spaltenkopf (Ausbaustufe 11).
 *
 * Jede Tabelle mit <thead> bekommt je Spaltenkopf mit Text einen Button
 * (⇅ unsortiert, ▲ aufsteigend, ▼ absteigend). Ausnahmen: th[data-nosort],
 * table[data-sort-mode="server"] (z.B. /buchungen, sortiert serverseitig).
 * Sortierwert: td[data-sort] (Zahl Punkt-dezimal / ISO-Datum) oder der
 * angezeigte Text (Datum TT.MM.JJJJ, deutscher Betrag 1.234,56, sonst Text).
 * Leere Werte und „–" stehen in beiden Richtungen am Ende. Zeilen mit
 * data-sort-follow (Bearbeiten-/Tilgungsplan-Zeilen) wandern mit ihrer
 * Vorzeile. Nur <tbody> wird sortiert, <tfoot> bleibt unberührt.
 * Initialisierung über htmx.onLoad - greift auch bei nachgeladenen Fragmenten.
 */
(function () {
  'use strict';

  var DE_DATE = /^(\d{2})\.(\d{2})\.(\d{4})$/;
  var DE_NUMBER = /^-?(\d{1,3}(\.\d{3})+|\d+)(,\d+)?$/;
  var PLAIN_NUMBER = /^-?\d+(\.\d+)?$/;
  var ICON = { none: '⇅', asc: '▲', desc: '▼' };
  var collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });

  function sortKey(raw, fromDataSort) {
    var text = String(raw == null ? '' : raw).trim();
    if (text === '' || text === '–' || text === '-') { return null; }
    if (fromDataSort && PLAIN_NUMBER.test(text)) {
      return { kind: 'number', value: parseFloat(text), text: text };
    }
    var m = DE_DATE.exec(text);
    if (m) { return { kind: 'date', value: parseInt(m[3] + m[2] + m[1], 10), text: text }; }
    var num = text.replace(/\s*[€%]$/, '').replace(/\s/g, '');
    if (DE_NUMBER.test(num)) {
      return { kind: 'number', value: parseFloat(num.replace(/\./g, '').replace(',', '.')), text: text };
    }
    return { kind: 'text', value: text, text: text };
  }

  function columnKind(keys) {
    var kind = null;
    for (var i = 0; i < keys.length; i++) {
      if (!keys[i]) { continue; }
      if (kind === null) { kind = keys[i].kind; } else if (kind !== keys[i].kind) { return 'text'; }
    }
    return kind || 'text';
  }

  function sortItems(items, dir) {
    var kind = columnKind(items.map(function (it) { return it.key; }));
    var sign = dir === 'desc' ? -1 : 1;
    return items.slice().sort(function (a, b) {
      if (!a.key || !b.key) {
        if (a.key) { return -1; }
        if (b.key) { return 1; }
        return a.index - b.index;
      }
      var c = kind === 'text' ? collator.compare(a.key.text, b.key.text)
                              : a.key.value - b.key.value;
      return c !== 0 ? sign * c : a.index - b.index;
    });
  }

  function groupRows(rows, isFollow) {
    var groups = [];
    rows.forEach(function (row) {
      if (isFollow(row) && groups.length) { groups[groups.length - 1].push(row); }
      else { groups.push([row]); }
    });
    return groups;
  }

  function sortTable(table, col, dir) {
    var tbody = table.tBodies[0];
    if (!tbody) { return; }
    var groups = groupRows(Array.prototype.slice.call(tbody.rows), function (r) {
      return r.hasAttribute('data-sort-follow');
    });
    var items = groups.map(function (g, i) {
      var cell = g[0].cells[col];
      var key = null;
      if (cell && cell.colSpan === 1) {
        key = cell.hasAttribute('data-sort')
          ? sortKey(cell.getAttribute('data-sort'), true)
          : sortKey(cell.textContent, false);
      }
      return { key: key, index: i, rows: g };
    });
    sortItems(items, dir).forEach(function (it) {
      it.rows.forEach(function (r) { tbody.appendChild(r); });
    });
  }

  function initTable(table) {
    if (table.hasAttribute('data-sort-init') || !table.tHead || !table.tHead.rows.length
        || table.getAttribute('data-sort-mode') === 'server') { return; }
    table.setAttribute('data-sort-init', '');
    var doc = table.ownerDocument;
    Array.prototype.forEach.call(table.tHead.rows[0].cells, function (th) {
      var label = th.textContent.trim();
      if (!label || th.hasAttribute('data-nosort')) { return; }
      var btn = doc.createElement('button');
      btn.type = 'button';
      btn.className = 'sort-btn';
      btn.textContent = ICON.none;
      btn.setAttribute('aria-label', 'Nach ' + label + ' sortieren');
      btn.addEventListener('click', function () {
        var dir = btn.getAttribute('data-dir') === 'asc' ? 'desc' : 'asc';
        Array.prototype.forEach.call(table.tHead.querySelectorAll('.sort-btn'), function (b) {
          b.removeAttribute('data-dir');
          b.classList.remove('active');
          b.textContent = ICON.none;
        });
        btn.setAttribute('data-dir', dir);
        btn.classList.add('active');
        btn.textContent = ICON[dir];
        sortTable(table, th.cellIndex, dir);
      });
      th.appendChild(doc.createTextNode(' '));
      th.appendChild(btn);
    });
  }

  function init(root) {
    if (!root || !root.querySelectorAll) { return; }
    if (root.tagName === 'TABLE') { initTable(root); }
    Array.prototype.forEach.call(root.querySelectorAll('table'), initTable);
  }

  var api = { sortKey: sortKey, columnKind: columnKind, sortItems: sortItems,
              groupRows: groupRows, sortTable: sortTable, initTable: initTable, init: init };
  if (typeof module !== 'undefined' && module.exports) { module.exports = api; }
  if (typeof window !== 'undefined') {
    window.FBSort = api;
    if (window.htmx) { window.htmx.onLoad(init); }
  }
})();
  • Step 7: Einbinden + CSS

base.html: direkt nach <script src="/static/htmx.min.js"></script>:

  <script src="/static/sort.js"></script>

(htmx.onLoad feuert nach DOMContentLoaded für body und danach für jedes per htmx eingesetzte Fragment.)

style.css anhängen:

/* Sortier-Buttons in Spaltenköpfen (Ausbaustufe 11, static/sort.js). */
.sort-btn {
  border: none;
  background: none;
  padding: 0 0.2rem;
  margin: 0;
  font-size: 0.8em;
  color: #888;
  cursor: pointer;
  text-decoration: none;
}
.sort-btn.active {
  color: inherit;
  font-weight: bold;
}
  • Step 8: Template-Attribute setzen (jede Stelle einzeln):

planning.html:

  • <tr id="rec-edit-{{ r.id }}" hidden> → <tr id="rec-edit-{{ r.id }}" hidden data-sort-follow>
  • <tr id="pln-edit-{{ p.id }}" hidden> → ... hidden data-sort-follow>
  • <tr id="loan-edit-{{ l.id }}" hidden> → ... hidden data-sort-follow>
  • Tilgungsplan-Zeile (das <tr> direkt vor <td colspan="7"> mit <details ontoggle=...loadLoanSchedule) → <tr id="loan-plan-{{ l.id }}" data-sort-follow>
  • Wiederkehrend/Einmal/Vorschläge-Betrag-<td>: data-sort="{{ r.amount }}" / data-sort="{{ p.amount }}" / data-sort="{{ s.amount }}" ergänzen (Vorschlags-Zelle enthält den Hinweistext).
  • Kredite: Summe-<td data-sort="{{ l.principal }}">, Laufzeit-<td data-sort="{{ l.term_months }}">.

szenarien.html: mod-edit-… und spi-edit-… <tr> jeweils data-sort-follow ergänzen.

admin.html: <tr id="cat-edit-{{ c.id }}" hidden data-sort-follow>.

index.html: <th data-nosort>Umbenennen</th><th data-nosort>Anker pflegen</th>; Anker-<td> → <td data-sort="{{ account.anchor_date.isoformat() if account.anchor_date is not none else '' }}">; Saldo-<td ... data-sort="{{ balance }}">.

salden.html: die <tr class="total-row">…</tr> aus <tbody> herausnehmen und nach </tbody> in <tfoot>…</tfoot> setzen (Inhalt unverändert). Monats-<td> → <td data-sort="{{ row['month'].isoformat() }}">.

_import_list.html: Zeitraum-<td> → <td data-sort="{{ s.period_start.isoformat() if s.period_start else '' }}">.

transactions.html: <table> → <table data-sort-mode="server"> (Links folgen in Task 5).

Tilgungsplan (per JS erzeugt) bleibt bewusst unsortiert (kein htmx-Swap → kein onLoad).

  • Step 9: Tests grün + Gesamtsuite

Run: cd finance && .venv/bin/python -m pytest -q Expected: 240 + 6 = 246 passed, 3 skipped.

  • Step 10: Fable-Gate — JS-Tests per Podman (Host hat kein node):
cd finance && podman run --rm -v "$PWD":/w:ro -w /w docker.io/library/node:22-alpine \
  sh -c 'npm i --silent --no-save --prefix /tmp/j jsdom >/dev/null 2>&1; NODE_PATH=/tmp/j/node_modules node --test tests/js/'

Erwartet: alle Unit- und DOM-Tests grün (DOM-Tests NICHT geskippt). Zusätzlich jede Tabelle der GUI (lokaler uvicorn gegen Test-/SQLite-DB oder gerenderter HTML-Dump) auf korrekte data-sort/data-nosort/data-sort-follow durchsehen; Salden-Gesamtzeile im tfoot.

  • Step 11: Commit
git add finance/app/static/sort.js finance/app/static/style.css finance/app/templates/ \
        finance/tests/js finance/tests/test_sort_js.py finance/tests/test_sort_templates.py
git commit -m "feat: Tabellen per Spaltenkopf sortierbar (A11-T4)"

Task 5: Serverseitige Sortierung /buchungen

Files:

  • Modify: finance/app/routers/transactions.py (SORT_COLUMNS, normalize_sort, _apply_sort, list_transactions)
  • Modify: finance/app/routers/gui.py (buchungen)
  • Modify: finance/app/templates/transactions.html (Sortier-Links, Pager)
  • Create: finance/tests/test_transactions_sort.py

Interfaces:

  • Consumes: data-sort-mode="server" an der Buchungstabelle (Task 4); CSS .sort-btn (Task 4).

  • Produces: app.routers.transactions.normalize_sort(sort: str | None, dir: str | None) -> tuple[str, str]; list_transactions(..., sort: str = "datum", dir: str = "asc", ...). Erlaubte Schlüssel: datum, konto, zweck, empfaenger, betrag, kategorie.

  • Step 1: Failing Tests — finance/tests/test_transactions_sort.py:

from datetime import date, timedelta
from decimal import Decimal

import pytest

from app.models.tables import Account, Category, Transaction

H = {"Authorization": "Bearer test-key"}


def _tx(db, acc, day, amount, purpose="Zweck", counterparty="X", category=None, n=[0]):
    n[0] += 1
    db.add(Transaction(account_id=acc.id, booking_date=day, amount=Decimal(amount),
                       purpose=purpose, counterparty=counterparty, status="confirmed",
                       category_id=category.id if category else None,
                       dedup_hash=f"sort-{n[0]}"))


@pytest.fixture
def small(db):
    zeta = Account(bank="t", iban="DE-SO-1", name="Zeta", type="giro")
    alpha = Account(bank="t", iban="DE-SO-2", name="alpha", type="giro")
    kat = Category(name="Miete")
    db.add_all([zeta, alpha, kat])
    db.flush()
    _tx(db, zeta, date(2026, 1, 3), "-5.00", "b-Zweck", "Bert", kat)
    _tx(db, alpha, date(2026, 1, 1), "20.00", "A-Zweck", "anna", None)
    _tx(db, zeta, date(2026, 1, 2), "-30.00", "c-Zweck", "Carl", kat)
    db.commit()


def _amounts(client, qs):
    r = client.get(f"/api/transactions?{qs}", headers=H)
    assert r.status_code == 200
    return [Decimal(t["amount"]) for t in r.json()]


def test_standard_bleibt_datum_aufsteigend(client, small):
    assert _amounts(client, "") == [Decimal("20.00"), Decimal("-30.00"), Decimal("-5.00")]


@pytest.mark.parametrize("sort,dir,expected", [
    ("betrag", "asc", ["-30.00", "-5.00", "20.00"]),
    ("betrag", "desc", ["20.00", "-5.00", "-30.00"]),
    ("datum", "desc", ["-5.00", "-30.00", "20.00"]),
    ("konto", "asc", ["20.00", "-5.00", "-30.00"]),       # alpha vor Zeta (case-insensitiv), Gleichstand -> id
    ("zweck", "asc", ["20.00", "-5.00", "-30.00"]),       # A-, b-, c-Zweck
    ("empfaenger", "desc", ["-30.00", "-5.00", "20.00"]),  # Carl, Bert, anna
    ("kategorie", "asc", ["-5.00", "-30.00", "20.00"]),   # Miete (id-Reihenfolge), unkategorisiert zuletzt
    ("kategorie", "desc", ["-30.00", "-5.00", "20.00"]),  # unkategorisiert auch absteigend zuletzt
])
def test_sortierung_je_spalte(client, small, sort, dir, expected):
    assert _amounts(client, f"sort={sort}&dir={dir}") == [Decimal(e) for e in expected]


def test_ungueltige_werte_fallen_auf_standard(client, small):
    assert _amounts(client, "sort=foo;drop&dir=hoch") == _amounts(client, "")
    client.post("/login", data={"username": "admin", "password": "geheim"})
    assert client.get("/buchungen?sort=foo&dir=bar").status_code == 200


@pytest.fixture
def many(db):
    acc = Account(bank="t", iban="DE-SO-3", name="Viel", type="giro")
    db.add(acc)
    db.flush()
    for i in range(1, 61):  # Beträge i,37; Datum verwürfelt
        _tx(db, acc, date(2026, 1, 1) + timedelta(days=(i * 7) % 60), f"{i}.37", f"Miete {i}")
    db.commit()


def test_sortierung_gilt_ueber_alle_seiten(client, many):
    client.post("/login", data={"username": "admin", "password": "geheim"})
    p1 = client.get("/buchungen?sort=betrag&dir=desc").text
    p2 = client.get("/buchungen?sort=betrag&dir=desc&page=2").text
    assert ">60,37</td>" in p1 and ">1,37</td>" not in p1
    assert ">1,37</td>" in p2 and ">60,37</td>" not in p2


def test_links_erhalten_filter_und_sortierung(client, many):
    client.post("/login", data={"username": "admin", "password": "geheim"})
    html = client.get("/buchungen?q=Miete&sort=betrag&dir=asc").text
    # Aktiver Spaltenkopf kehrt die Richtung um, übernimmt den Filter, ohne page.
    assert 'href="/buchungen?q=Miete&status=confirmed&sort=betrag&dir=desc"' in html
    # Inaktive Spalte startet aufsteigend.
    assert 'href="/buchungen?q=Miete&status=confirmed&sort=datum&dir=asc"' in html
    # Pager behält Filter + Sortierung.
    assert "q=Miete&status=confirmed&sort=betrag&dir=asc&page=2" in html
    assert "▲" in html

Hinweis zur Link-Reihenfolge: die Parameter-Reihenfolge ergibt sich aus dem filter_params-Dict (account_id, date_from, date_to, category_id, q, status); leere Werte entfallen — wie beim bestehenden Pager.

  • Step 2: Tests laufen lassen — müssen fehlschlagen

Run: cd finance && .venv/bin/python -m pytest -q tests/test_transactions_sort.py Expected: FAIL (Sortierung wird ignoriert, Links fehlen). test_standard_bleibt_datum_aufsteigend darf grün sein.

  • Step 3: Sortierlogik in routers/transactions.py (vor list_transactions einfügen):
# Serverseitige Sortierung der Buchungsliste (Ausbaustufe 11): Whitelist
# URL-Schlüssel -> Sortierausdruck. Textspalten über lower(), damit SQLite
# (Tests) und Postgres gleich (case-insensitiv) sortieren.
SORT_COLUMNS = {
    "datum": Transaction.booking_date,
    "konto": func.lower(Account.name),
    "zweck": func.lower(Transaction.purpose),
    "empfaenger": func.lower(Transaction.counterparty),
    "betrag": Transaction.amount,
    "kategorie": func.lower(Category.name),
}


def normalize_sort(sort: str | None, dir: str | None) -> tuple[str, str]:
    """Unbekannte Werte fallen still auf den Standard (datum/asc) zurück -
    kein 422 für manipulierte oder veraltete Links."""
    return (sort if sort in SORT_COLUMNS else "datum",
            dir if dir in ("asc", "desc") else "asc")


def _apply_sort(stmt, sort: str | None, dir: str | None):
    sort, dir = normalize_sort(sort, dir)
    if sort == "konto":
        stmt = stmt.outerjoin(Account, Account.id == Transaction.account_id)
    elif sort == "kategorie":
        stmt = stmt.outerjoin(Category, Category.id == Transaction.category_id)
    col = SORT_COLUMNS[sort]
    if dir == "desc":
        return stmt.order_by(col.desc().nulls_last(), Transaction.id.desc())
    return stmt.order_by(col.asc().nulls_last(), Transaction.id.asc())

In list_transactions Parameter sort: str = "datum", dir: str = "asc", vor limit ergänzen und die order_by-Zeile ersetzen durch:

    stmt = _apply_sort(stmt, sort, dir).offset(offset).limit(limit)

(Standard datum/asc ergibt booking_date ASC NULLS LAST, id ASC = bisherige Reihenfolge.)

  • Step 4: gui.buchungen — Import normalize_sort ergänzen (from app.routers.transactions import count_transactions, list_transactions, normalize_sort). Parameter sort: str = "datum", dir: str = "asc", nach page ergänzen; nach page = max(1, page): sort, dir = normalize_sort(sort, dir); an list_transactions(...) zusätzlich sort=sort, dir=dir, übergeben; im Template-Kontext "sort": sort, "dir": dir, ergänzen.

  • Step 5: Template transactions.html

Vor <table data-sort-mode="server"> einfügen (ersetzt das bisherige pager_params im Pager):

{% set filter_params = {
  'account_id': filters.account_id,
  'date_from': filters.date_from.isoformat() if filters.date_from else None,
  'date_to': filters.date_to.isoformat() if filters.date_to else None,
  'category_id': filters.category_id,
  'q': filters.q,
  'status': filters.status,
} %}
{% macro sort_th(key, label, params, cur_sort, cur_dir) -%}
  {%- set active = cur_sort == key -%}
  <th>{{ label }} <a class="sort-btn{{ ' active' if active else '' }}" aria-label="Nach {{ label }} sortieren"
     href="/buchungen?{% for k, v in params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}sort={{ key }}&dir={{ 'desc' if active and cur_dir == 'asc' else 'asc' }}">{{ ('▲' if cur_dir == 'asc' else '▼') if active else '⇅' }}</a></th>
{%- endmacro %}

Kopfzeile ersetzen:

    <tr>
      {{ sort_th('datum', 'Datum', filter_params, sort, dir) }}{{ sort_th('konto', 'Konto', filter_params, sort, dir) }}
      {{ sort_th('zweck', 'Verwendungszweck', filter_params, sort, dir) }}{{ sort_th('empfaenger', 'Empfänger/Zahler', filter_params, sort, dir) }}
      {{ sort_th('betrag', 'Betrag (€)', filter_params, sort, dir) }}{{ sort_th('kategorie', 'Kategorie', filter_params, sort, dir) }}<th></th>
    </tr>

Pager: den {% set pager_params = … %}-Block entfernen; beide Pager-Links auf filter_params umstellen und vor page= sort={{ sort }}&dir={{ dir }}& einfügen, z.B.:

  <a href="/buchungen?{% for k, v in filter_params.items() %}{% if v %}{{ k }}={{ v|urlencode }}&{% endif %}{% endfor %}sort={{ sort }}&dir={{ dir }}&page={{ page - 1 }}">Zurück</a>

(Weiter analog mit page + 1.)

  • Step 6: Tests grün + Gesamtsuite

Run: cd finance && .venv/bin/python -m pytest -q Expected: 246 + 12 = 258 passed, 3 skipped. Bestehende Pager-Tests (test_gui.py „Weiter", ?page=2) weiterhin grün.

  • Step 7: Fable-Gate — Live-Approximation gegen Wegwerf-Postgres (Collation en_US.utf8 wie Prod): FB_DATABASE_URL auf Wegwerf-DB, alembic upgrade head, künstliche Buchungen, uvicorn lokal, jede Spalte auf/ab per curl inkl. Seitengrenze und Filter; manipulierte Parameter (sort=%27;--, dir=) → 200.

  • Step 8: Commit

git add finance/app/routers/transactions.py finance/app/routers/gui.py \
        finance/app/templates/transactions.html finance/tests/test_transactions_sort.py
git commit -m "feat: Buchungen serverseitig nach Spalte sortierbar (A11-T5)"

Task 6: Doku + Release v0.11.0 + Live-Smoke

Files:

  • Modify: docs/ARCHITEKTUR.md
  • Modify: finance/app/templates/hilfe.html
  • Modify: finance/VERSION
  • Modify: docs/superpowers/plans/2026-09-28-ausbaustufe-11.md (Haken)
  • Modify: .superpowers/sdd/progress.md

Interfaces:

  • Consumes: alles aus Task 1–5.

  • Step 1: docs/ARCHITEKTUR.md — Komponenten-Diagramm: S_projection um refresh_stale_projections ergänzen; neuen Knoten S_planning_chart["planning_chart.py<br/>refresh_planning_chart"] mit Kanten von gui.py und zur DB; M_views unverändert benannt, im Abschnitt 4 (Zentrale Konzepte) kurz: v_projection = System-Ist + Szenarien ab heute, reservierter Name; Tabelle planning_monthly (abgeleitet, bei GUI-Aufruf neu geschrieben, Advisory-Locks 110001/110002); static/sort.js + serverseitige Buchungs-Sortierung. Jeder im Diagramm genannte Name muss real im Code existieren (CLAUDE.md-Regel).

  • Step 2: hilfe.html — im Konzepte-Abschnitt: Szenarien-Absatz um „Löschen" und automatisches Neurechnen veralteter Szenarien ergänzen; Grafana-Absatz um „System-Ist"-Kurve und Panel „Planung nach Kategorie"; ein Satz zur Sortierung per Spaltenkopf (⇅/▲/▼; Buchungen über alle Seiten).

  • Step 3: Version — finance/VERSION: 0.10.0 → 0.11.0. Suite: cd finance && .venv/bin/python -m pytest -q → 258 passed, 3 skipped (bzw. aktueller Stand, alle grün).

  • Step 4: Commit Release

git add docs/ARCHITEKTUR.md finance/app/templates/hilfe.html finance/VERSION
git commit -m "chore: Release v0.11.0 (Szenario-Löschen, Grafana-Korrekturen, Tabellensortierung)"
  • Step 5: Backup + Redeploy — ./backup_finance_pod.sh (vor Migration), dann ./create_pod_finance.sh. Prüfen: curl -s 127.0.0.1:8096/api/version → 0.11.0; alembic current im API-Container → d5e8b3c7a410.

  • Step 6: Live-Smoke (Details nur Chat/Scratchpad, DATENSCHUTZ):

    • Seiten /, /buchungen, /salden, /import, /planung, /szenarien, /admin, /hilfe → 200.
    • Nach Aufruf von /: alle bereits gerechneten Live-Szenarien haben ersten Projektionspunkt = morgen (SQL min(day) je Szenario, ohne Beträge ausgeben).
    • v_projection: Serie System-Ist endet heute; keine Szenario-Zeile < heute.
    • planning_monthly nicht leer, enthält Kategorie Kreditraten genau dann, wenn Kredite existieren.
    • Grafana-API /api/dashboards/uid/finanzen → 5 Panels.
    • Wegwerf-Szenario SMOKE-A11 anlegen → per API (bzw. Button-Ziel) löschen → 204, keine Waisen in projection_points/scenario_modifiers.
    • POST /api/scenarios {"name":"System-Ist"} → 422.
    • /buchungen?sort=betrag&dir=desc → 200, erste Zeile = betragsgrößte Buchung (nur prüfen, nicht zitieren).
    • Bestandsdaten unverändert: Anzahl confirmed Buchungen, Konten, Kategorien, Szenarien wie vor dem Deploy.
  • Step 7: Fable-Release-Gate (Whole-Branch dd6110b..HEAD) + Ledger-Eintrag „AUSBAUSTUFE 11 ABGESCHLOSSEN" in .superpowers/sdd/progress.md; Plan-Haken setzen; commit docs: A11 Ledger + Plan-Haken.