feat: Dashboard – Donut ohne Überlauf, Achse lesbar, Karten-Optionen

- Budget-Donut: Mittelbetrag passt sich der Länge an (kein Hineinlaufen in den
  Ring bei 4-stelligen Beträgen)
- Vermögensverlauf: Y-Achse ohne Währungssymbol (formatAmount) + etwas mehr
  linker Rand → „CHF …" wird nicht mehr abgeschnitten
- Karten-Optionen: im Anordnen-Modus je Karte einstellbar; Budget-Donut und
  Budget-Fortschritt können nach Gruppen ODER Kategorien aufschlüsseln
  (?group_by=), Auswahl wird im Layout (props) mitgespeichert

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-20 16:58:32 +02:00
co-authored by Claude Opus 4.8
parent d1cab19264
commit 8d0fbdfe2b
9 changed files with 148 additions and 41 deletions
+43 -21
View File
@@ -99,47 +99,69 @@ def _expense_groups(db: Session, user: User) -> list[CategoryGroup]:
)
def _units(groups: list[CategoryGroup], by_category: bool):
"""Liefert je Anzeige-Einheit (name, farbe, [kategorie-ids]). Bei ``by_category``
ist jede Kategorie eine eigene Einheit, sonst jede Gruppe (Summe ihrer Kategorien)."""
for g in groups:
if by_category:
for c in g.categories:
if not c.is_archived:
yield (c.name, c.color, [c.id])
else:
yield (g.name, g.color, [c.id for c in g.categories if not c.is_archived])
@router.get("/budget-donut")
def budget_donut(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
def budget_donut(
group_by: str = Query(default="group"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
planned = budget.effective_planned(db, user, label)
groups = _expense_groups(db, user)
by_category = group_by == "category"
segments = []
total = 0
for g in groups:
val = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
if val > 0:
segments.append({"name": g.name, "value": val, "color": g.color})
total += val
def collect(values: dict[int, int]):
segs, tot = [], 0
for name, color, ids in _units(groups, by_category):
val = sum(values.get(i, 0) for i in ids)
if val > 0:
segs.append({"name": name, "value": val, "color": color})
tot += val
return segs, tot
segments, total = collect(planned)
mode = "planned"
if total == 0:
actual = budget.actual_expense_by_category(db, user, start, end, base)
for g in groups:
val = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
if val > 0:
segments.append({"name": g.name, "value": val, "color": g.color})
total += val
segments, total = collect(budget.actual_expense_by_category(db, user, start, end, base))
mode = "actual"
return {"segments": segments, "total": total, "currency": base, "mode": mode, "label": label}
return {"segments": segments, "total": total, "currency": base, "mode": mode,
"label": label, "group_by": "category" if by_category else "group"}
@router.get("/budget-progress")
def budget_progress(db: Session = Depends(get_db), user: User = Depends(get_current_user)) -> dict:
def budget_progress(
group_by: str = Query(default="group"),
db: Session = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
base = base_currency(db, user)
label = _label(db, user)
start, end = budget.period_bounds(label, _start_day(db, user))
planned = budget.effective_planned(db, user, label)
actual = budget.actual_expense_by_category(db, user, start, end, base)
by_category = group_by == "category"
rows = []
for g in _expense_groups(db, user):
gp = sum(planned.get(c.id, 0) for c in g.categories if not c.is_archived)
ga = sum(actual.get(c.id, 0) for c in g.categories if not c.is_archived)
for name, color, ids in _units(_expense_groups(db, user), by_category):
gp = sum(planned.get(i, 0) for i in ids)
ga = sum(actual.get(i, 0) for i in ids)
if gp > 0 or ga > 0:
rows.append({"name": g.name, "color": g.color, "planned": gp, "actual": ga, "over": ga > gp})
return {"rows": rows, "currency": base, "label": label}
rows.append({"name": name, "color": color, "planned": gp, "actual": ga, "over": ga > gp})
return {"rows": rows, "currency": base, "label": label,
"group_by": "category" if by_category else "group"}
@router.get("/income-expense")