feat: Aufteilung – Drill-down in Kategorie per Klick auf Tortensegment (A13-T6)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:45:44 +02:00
co-authored by Claude Opus 5.5
parent 4b6144d812
commit 3ef20034d3
7 changed files with 223 additions and 8 deletions
+72 -3
View File
@@ -3,6 +3,7 @@ import re
from datetime import date, timedelta from datetime import date, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Callable from typing import Callable
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.templating import Jinja2Templates from fastapi.templating import Jinja2Templates
@@ -14,8 +15,8 @@ from app.config import get_settings
from app.db import get_session from app.db import get_session
from app.engine.loans import add_months from app.engine.loans import add_months
from app.engine.recurrence import occurrences from app.engine.recurrence import occurrences
from app.engine.breakdown import FREI, BreakdownRow, Filters, share from app.engine.breakdown import FREI, BreakdownRow, Filters, category_rows, share
from app.engine.pie import pie_slices from app.engine.pie import UEBRIGE, pie_slices
from app.formats import de_label, eur, pct from app.formats import de_label, eur, pct
from app.models.tables import (Account, Category, PlannedItem, from app.models.tables import (Account, Category, PlannedItem,
ProjectionResult, RecurringItem, ProjectionResult, RecurringItem,
@@ -453,12 +454,80 @@ def _aufteilung_context(session: Session, today: date, monat: str | None,
return ctx return ctx
def _basis_query(ctx: dict) -> dict:
q = {"monat": ctx["monat_wert"], "szenario": ctx["szenario_id"] or "", "gesendet": "1"}
f = ctx["filters"]
for key, on in (("fixposten", f.recurring), ("einmal", f.planned), ("kredite", f.loans)):
if on:
q[key] = "1"
return q
def _drilldown(ctx: dict, kategorie: str | None, sammel: str | None) -> None:
"""Links der Haupttorte + ggf. zweite Torte. Die Sammelgruppe „Übrige"
läuft über sammel=1, damit eine echte Kategorie „Übrige" eindeutig bleibt."""
ctx["detail"] = None
ctx["detail_hinweis"] = None
if ctx["b"] is None:
return
sammel_gewaehlt = sammel == "1"
basis = _basis_query(ctx)
for seg in ctx["haupt"]:
s = seg["s"]
if s.color == "frei":
continue
extra = {"sammel": "1"} if s.merged else {"kategorie": s.label}
seg["q"] = urlencode({**basis, **extra})
seg["gewaehlt"] = s.merged if sammel_gewaehlt else (kategorie == s.label and not s.merged)
if sammel_gewaehlt:
merged = next((seg["s"] for seg in ctx["haupt"] if seg["s"].merged), None)
titel = UEBRIGE
enthaelt = list(merged.members) if merged else []
elif kategorie:
titel, enthaelt = kategorie, []
else:
return
rows = category_rows(ctx["b"].rows, set(enthaelt) if sammel_gewaehlt else {kategorie})
if not rows:
ctx["detail_hinweis"] = f"Kategorie „{titel}“ hat in dieser Auswahl keine Kosten."
return
income = ctx["s"].income
segmente = _segmente(pie_slices([(r.name, -r.amount) for r in rows]))
for seg in segmente:
anteil = share(seg["s"].value, income)
seg["zusatz"] = f"{pct(anteil)} der Einkünfte" if anteil is not None else None
ctx["detail"] = {"titel": titel, "enthaelt": enthaelt, "segmente": segmente,
"summe": sum((-r.amount for r in rows), Decimal("0"))}
@router.get("/aufteilung", dependencies=[Depends(gui_session)]) @router.get("/aufteilung", dependencies=[Depends(gui_session)])
def aufteilung_page(request: Request, monat: str | None = None, def aufteilung_page(request: Request, monat: str | None = None,
szenario: str | None = None, fixposten: str | None = None, szenario: str | None = None, fixposten: str | None = None,
einmal: str | None = None, kredite: str | None = None, einmal: str | None = None, kredite: str | None = None,
gesendet: str | None = None, gesendet: str | None = None, kategorie: str | None = None,
sammel: str | None = None,
session: Session = Depends(get_session)): session: Session = Depends(get_session)):
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten, ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
einmal, kredite, gesendet) einmal, kredite, gesendet)
_drilldown(ctx, kategorie, sammel)
return templates.TemplateResponse(request, "aufteilung.html", ctx) return templates.TemplateResponse(request, "aufteilung.html", ctx)
@router.get("/aufteilung/kategorie", dependencies=[Depends(gui_session)])
def aufteilung_kategorie_fragment(request: Request, monat: str | None = None,
szenario: str | None = None,
fixposten: str | None = None,
einmal: str | None = None,
kredite: str | None = None,
gesendet: str | None = None,
kategorie: str | None = None,
sammel: str | None = None,
session: Session = Depends(get_session)):
"""htmx-Fragment: zweite Torte + Haupttorte out-of-band (Markierung)."""
ctx = _aufteilung_context(session, date.today(), monat, szenario, fixposten,
einmal, kredite, gesendet)
_drilldown(ctx, kategorie, sammel)
if ctx["error"]:
ctx["detail_hinweis"] = ctx["error"]
ctx["oob"] = ctx["b"] is not None
return templates.TemplateResponse(request, "_aufteilung_kategorie.html", ctx)
+6
View File
@@ -273,3 +273,9 @@ label:has(input:disabled), label:has(select:disabled) {
.swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: none; } .swatch { display: inline-block; width: 0.8rem; height: 0.8rem; border-radius: 2px; flex: none; }
.legende-wert { margin-left: auto; color: #555; white-space: nowrap; } .legende-wert { margin-left: auto; color: #555; white-space: nowrap; }
.hinweis-klein { font-size: 0.85rem; color: #555; } .hinweis-klein { font-size: 0.85rem; color: #555; }
.torte-svg .seg.klickbar { cursor: pointer; }
.torte-svg .seg.klickbar:hover { opacity: 0.85; }
.torte-svg .seg.gewaehlt { stroke: #222; stroke-width: 0.05; }
.legende li.gewaehlt { font-weight: bold; }
#aufteilung-kategorie h2 { font-size: 1.1rem; margin-top: 1.5rem; }
@@ -0,0 +1,9 @@
{% from "_torte_macros.html" import torte %}
{% if detail_hinweis %}
<div class="warning">{{ detail_hinweis }}</div>
{% elif detail %}
<h2>{{ detail.titel }}: {{ detail.summe|eur }} € / Monat</h2>
{% if detail.enthaelt %}<p class="hinweis-klein">Enthält: {{ detail.enthaelt|join(', ') }}</p>{% endif %}
{{ torte(detail.segmente, 'Posten: ' ~ detail.titel) }}
{% endif %}
{% if oob %}{% include "_aufteilung_torte.html" %}{% endif %}
+1 -1
View File
@@ -1,4 +1,4 @@
{% from "_torte_macros.html" import torte %} {% from "_torte_macros.html" import torte %}
<div id="aufteilung-torte"> <div id="aufteilung-torte"{% if oob %} hx-swap-oob="true"{% endif %}>
{{ torte(haupt, haupt_titel) }} {{ torte(haupt, haupt_titel) }}
</div> </div>
+15 -4
View File
@@ -1,18 +1,29 @@
{# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts {# Torte + Legende (Ausbaustufe 13). segmente: Liste von Dicts
{s: engine.pie.Slice, q: Query-String für den Drill-down oder None, {s: engine.pie.Slice, q: Query-String für den Drill-down oder None,
gewaehlt: bool, zusatz: Zusatztext oder None}. #} gewaehlt: bool, zusatz: Zusatztext oder None}.
Klickbar sind SVG-Segmente (hx-get direkt am <path>, kein SVG-<a>, weil
htmx nur HTML-Links am Navigieren hindert) und die Legenden-Links (auch
ohne JS und per Tastatur nutzbar). #}
{% macro seg_attrs(seg) -%}
class="seg {{ seg.s.color }}{% if seg.q %} klickbar{% endif %}{% if seg.gewaehlt %} gewaehlt{% endif %}"
{%- if seg.q %} hx-get="/aufteilung/kategorie?{{ seg.q }}" hx-target="#aufteilung-kategorie" hx-push-url="/aufteilung?{{ seg.q }}"{% endif %}
{%- endmacro %}
{% macro torte(segmente, titel) %} {% macro torte(segmente, titel) %}
<figure class="torte"> <figure class="torte">
<figcaption>{{ titel }}</figcaption> <figcaption>{{ titel }}</figcaption>
<svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}"> <svg class="torte-svg" viewBox="-1.05 -1.05 2.1 2.1" role="img" aria-label="{{ titel }}">
{% for seg in segmente %}{% set s = seg.s %} {% for seg in segmente %}{% set s = seg.s %}
{% if s.path %}<path d="{{ s.path }}" class="seg {{ s.color }}"><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></path> {% if s.path %}<path d="{{ s.path }}" {{ seg_attrs(seg) }}><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></path>
{% else %}<circle r="1" class="seg {{ s.color }}"><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %} {% else %}<circle r="1" {{ seg_attrs(seg) }}><title>{{ s.label }}: {{ s.value|eur }} € ({{ s.share|pct }})</title></circle>{% endif %}
{% endfor %} {% endfor %}
</svg> </svg>
<ul class="legende"> <ul class="legende">
{% for seg in segmente %}{% set s = seg.s %} {% for seg in segmente %}{% set s = seg.s %}
<li><span class="swatch {{ s.color }}"></span><span>{{ s.label }}</span><span class="legende-wert">{{ s.value|eur }} € · {{ s.share|pct }}{% if seg.zusatz %} · {{ seg.zusatz }}{% endif %}</span></li> <li{% if seg.gewaehlt %} class="gewaehlt"{% endif %}><span class="swatch {{ s.color }}"></span>
{%- if seg.q %}<a href="/aufteilung?{{ seg.q }}" hx-get="/aufteilung/kategorie?{{ seg.q }}" hx-target="#aufteilung-kategorie" hx-push-url="/aufteilung?{{ seg.q }}">{{ s.label }}</a>
{%- else %}<span>{{ s.label }}</span>{% endif %}
<span class="legende-wert">{{ s.value|eur }} € · {{ s.share|pct }}{% if seg.zusatz %} · {{ seg.zusatz }}{% endif %}</span></li>
{% endfor %} {% endfor %}
</ul> </ul>
</figure> </figure>
+2
View File
@@ -66,6 +66,8 @@
<p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p> <p>Gesamte Einkünfte: <strong>{{ s.income|eur }} €</strong></p>
<div> <div>
{% include "_aufteilung_torte.html" %} {% include "_aufteilung_torte.html" %}
<p class="hinweis-klein">Für Kategorie-Aufteilung auf Segment klicken</p>
<div id="aufteilung-kategorie">{% include "_aufteilung_kategorie.html" %}</div>
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}
+118
View File
@@ -158,3 +158,121 @@ def test_tabelle_scrollbar_torte_darunter(client, db):
t = _get(client, monat=MONAT).text t = _get(client, monat=MONAT).text
assert re.search(r'<div class="tabelle-scroll">\s*<table', t) assert re.search(r'<div class="tabelle-scroll">\s*<table', t)
assert t.index('id="aufteilung-torte"') > t.index("</table>") assert t.index('id="aufteilung-torte"') > t.index("</table>")
def _drill(client, pfad="/aufteilung/kategorie", **params):
return client.get(pfad, params={"monat": MONAT, **params})
def test_hinweistext_und_segment_links(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
assert "Für Kategorie-Aufteilung auf Segment klicken" in t
assert 'hx-get="/aufteilung/kategorie?' in t
assert "kategorie=Wohnen" in t
assert 'hx-target="#aufteilung-kategorie"' in t
assert '<div id="aufteilung-kategorie">' in t
def test_frei_verfuegbar_ohne_link(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT).text
assert "kategorie=Frei" not in t
frei = re.search(r'<path[^>]*class="seg frei[^"]*"[^>]*>', t).group(0)
assert "hx-get" not in frei
def test_fragment_zweite_torte_und_oob(client, db):
_login(client)
_daten(db)
r = _drill(client, kategorie="Wohnen")
assert r.status_code == 200
assert "Wohnen: 1.000,00 € / Monat" in r.text
assert "Miete" in r.text and "33,3 % der Einkünfte" in r.text
assert '<div id="aufteilung-torte" hx-swap-oob="true">' in r.text
assert "gewaehlt" in r.text
assert "<html" not in r.text
def test_seite_mit_kategorie_zeigt_zweite_torte(client, db):
_login(client)
_daten(db)
t = _get(client, monat=MONAT, kategorie="Versicherung").text
assert "Versicherung: 100,00 € / Monat" in t
assert 'hx-swap-oob' not in t
def test_unbekannte_kategorie_hinweis(client, db):
_login(client)
_daten(db)
for pfad in ("/aufteilung", "/aufteilung/kategorie"):
r = _drill(client, pfad, kategorie="Gibtsnicht")
assert r.status_code == 200
assert "Kategorie „Gibtsnicht“ hat in dieser Auswahl keine Kosten." in r.text
def test_fragment_bei_fehler_nur_hinweis(client):
_login(client)
r = client.get("/aufteilung/kategorie", params={"monat": "2030-13", "kategorie": "X"})
assert r.status_code == 200
assert "Ungültiger Monat" in r.text and "hx-swap-oob" not in r.text
def _viele_kategorien(db):
eink = Category(name="Einkommen")
kleine = [Category(name="Klein A"), Category(name="Klein B")]
gross = Category(name="Groß")
db.add_all([eink, gross, *kleine])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Posten G", amount=D("-1000.00"), rhythm="monthly", due_day=1, category_id=gross.id),
RecurringItem(name="Posten A", amount=D("-10.00"), rhythm="monthly", due_day=1, category_id=kleine[0].id),
RecurringItem(name="Posten B", amount=D("-20.00"), rhythm="monthly", due_day=1, category_id=kleine[1].id),
])
db.commit()
def test_sammelgruppe_uebrige(client, db):
_login(client)
_viele_kategorien(db)
t = _get(client, monat=MONAT).text
assert "sammel=1" in t
r = _drill(client, sammel="1")
assert "Übrige: 30,00 € / Monat" in r.text
assert "Enthält: Klein B, Klein A" in r.text
assert "Posten A" in r.text and "Posten B" in r.text
def test_echte_kategorie_uebrige_nicht_verwechselt(client, db):
_login(client)
_viele_kategorien(db)
echte = Category(name="Übrige")
db.add(echte)
db.flush()
db.add(RecurringItem(name="Echt", amount=D("-500.00"), rhythm="monthly", due_day=1,
category_id=echte.id))
db.commit()
r = _drill(client, kategorie="Übrige")
assert "Übrige: 500,00 € / Monat" in r.text
assert "Echt" in r.text and "Posten A" not in r.text
r = _drill(client, sammel="1")
assert "Übrige: 30,00 € / Monat" in r.text and "Echt" not in r.text
def test_drilldown_sonderzeichen(client, db):
_login(client)
eink, haus = Category(name="Einkommen"), Category(name="Haus & Garten")
db.add_all([eink, haus])
db.flush()
db.add_all([
RecurringItem(name="Gehalt", amount=D("3000.00"), rhythm="monthly", due_day=1, category_id=eink.id),
RecurringItem(name="Rasen", amount=D("-300.00"), rhythm="monthly", due_day=1, category_id=haus.id),
])
db.commit()
t = _get(client, monat=MONAT).text
assert "kategorie=Haus+%26+Garten" in t
r = _drill(client, kategorie="Haus & Garten")
assert "Haus &amp; Garten: 300,00 € / Monat" in r.text and "Rasen" in r.text