feat: Ringdiagramm faerbt Legende und Mitte nach Budget

Eingefaerbt war bisher nur die Listen-Darstellung der Ausgaben-Anteile. In der
Kreis-Darstellung blieben die Betraege der Legende grau, und die Summe in der
Mitte sagte gar nichts ueber den Stand.

Jetzt tragen beide die Aussage: gruen im geplanten Rahmen, rot darueber. Faehrt
man ein Segment an, zeigt die Mitte dessen Betrag – dann gilt dort das Budget
dieser Kategorie, sonst das des Ganzen.

Fuer die Mitte liefert der Server jetzt auch das Geplante ueber ALLE
Ausgaben-Einheiten, nicht nur ueber die mit Ausgaben: Der Monatsplan umfasst
auch, wofuer noch nichts ausgegeben wurde.

Ohne Plan bleibt alles, wie es war. Der Donut faerbt nur, wenn ein Segment die
Angabe mitbringt – die Budget-Karte, die dieselbe Komponente benutzt, bleibt
also unberuehrt.

Der Test hat dabei gleich einen Fehler gefunden: ``_units`` ist ein Generator,
und ich lief zweimal darueber – beim zweiten Mal war er leer und das Geplante
immer 0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Guido
2026-08-30 14:13:49 +02:00
co-authored by Claude Opus 5
parent 4d3b9a8207
commit 63056ae76a
5 changed files with 51 additions and 7 deletions
+12 -2
View File
@@ -164,8 +164,11 @@ def expense_share(
planned = budget.effective_planned(db, user, label) planned = budget.effective_planned(db, user, label)
by_category = group_by != "group" by_category = group_by != "group"
# Als Liste: ``_units`` ist ein Generator, und er wird hier zweimal
# gebraucht – beim zweiten Mal waere er leer und das Geplante 0.
einheiten = list(_units(_expense_groups(db, user), by_category))
rows = [] rows = []
for name, color, ids in _units(_expense_groups(db, user), by_category): for name, color, ids in einheiten:
val = sum(actual.get(i, 0) for i in ids) val = sum(actual.get(i, 0) for i in ids)
if val > 0: if val > 0:
geplant = sum(planned.get(i, 0) for i in ids) geplant = sum(planned.get(i, 0) for i in ids)
@@ -173,9 +176,16 @@ def expense_share(
"planned": geplant, "planned": geplant,
"over": geplant > 0 and val > geplant}) "over": geplant > 0 and val > geplant})
rows.sort(key=lambda r: r["amount"], reverse=True) rows.sort(key=lambda r: r["amount"], reverse=True)
# Das Geplante ueber ALLE Ausgaben-Einheiten, nicht nur die mit Ausgaben:
# Der Monatsplan umfasst auch, wofuer noch nichts ausgegeben wurde.
gesamt = sum(r["amount"] for r in rows)
gesamt_geplant = sum(planned.get(i, 0) for _, _, ids in einheiten for i in ids)
return { return {
"rows": rows, "rows": rows,
"total": sum(r["amount"] for r in rows), "total": gesamt,
"total_planned": gesamt_geplant,
"total_over": gesamt_geplant > 0 and gesamt > gesamt_geplant,
"currency": base, "currency": base,
"label": label, "label": label,
"group_by": "category" if by_category else "group", "group_by": "category" if by_category else "group",
+6
View File
@@ -209,3 +209,9 @@ def test_ausgabenanteile_nennen_das_geplante(admin):
plane(max(1, ist - 10000)) plane(max(1, ist - 10000))
assert zeile()["over"] is True, "ueberschritten" assert zeile()["over"] is True, "ueberschritten"
# Und die Mitte des Ringdiagramms bekommt dieselbe Grundlage fuer das
# Ganze – sonst faerbte sich die Legende, die Summe aber nicht.
ganz = admin.get("/api/dashboard/expense-share").json()
assert ganz["total_planned"] > 0
assert ganz["total_over"] == (ganz["total"] > ganz["total_planned"])
+6 -1
View File
@@ -160,8 +160,13 @@ function ExpenseShare({ props }) {
if ((props?.darstellung || "liste") === "kreis") { if ((props?.darstellung || "liste") === "kreis") {
return ( return (
<Donut <Donut
segmente={data.rows.map((r) => ({ name: r.name, value: r.amount, color: r.color }))} segmente={data.rows.map((r) => ({
name: r.name, value: r.amount, color: r.color,
// Ohne Plan keine Aussage – dort gibt es nichts zu überschreiten.
budget: r.planned > 0 ? (r.over ? "over" : "ok") : null,
}))}
gesamtLabel={t("cards.spent")} gesamtLabel={t("cards.spent")}
gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null}
fmt={(v) => formatMoney(v, data.currency)} fmt={(v) => formatMoney(v, data.currency)}
/> />
); );
+13 -4
View File
@@ -1,8 +1,11 @@
import { useState } from "react"; import { useState } from "react";
// Ringdiagramm für Anteile. segmente: [{ name, value, color }] // Ringdiagramm für Anteile. segmente: [{ name, value, color, budget }]
// `budget` ist "ok", "over" oder fehlt – fehlt es, wird nichts eingefärbt.
// `gesamtBudget` gilt für die Mitte, solange kein Segment angezeigt wird.
// fmt: Formatierer für die Werte (z. B. Geldbetrag). // fmt: Formatierer für die Werte (z. B. Geldbetrag).
export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v) => String(v) }) { export default function Donut({ segmente = [], gesamtLabel = "gesamt",
gesamtBudget = null, fmt = (v) => String(v) }) {
const [aktiv, setAktiv] = useState(null); const [aktiv, setAktiv] = useState(null);
const werte = segmente.filter((s) => s.value > 0); const werte = segmente.filter((s) => s.value > 0);
@@ -29,6 +32,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
// Mittelbetrag an die Stringlänge anpassen, damit ein 4-stelliger Betrag // Mittelbetrag an die Stringlänge anpassen, damit ein 4-stelliger Betrag
// (z. B. „CHF 3'865.00") nicht in den Ring läuft. // (z. B. „CHF 3'865.00") nicht in den Ring läuft.
const zentralText = fmt(gezeigt ? gezeigt.value : summe); const zentralText = fmt(gezeigt ? gezeigt.value : summe);
// Die Mitte zeigt beim Überfahren den Wert EINES Segments – dann gilt dessen
// Budget-Stand, sonst der des Ganzen.
const zentralBudget = gezeigt ? gezeigt.budget : gesamtBudget;
const wertGroesse = Math.max(11, Math.min(20, Math.floor(190 / Math.max(zentralText.length, 1)))); const wertGroesse = Math.max(11, Math.min(20, Math.floor(190 / Math.max(zentralText.length, 1))));
return ( return (
@@ -45,7 +51,8 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} /> onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)} />
))} ))}
</g> </g>
<text x={mitte} y={mitte - 4} textAnchor="middle" className="donut-value" <text x={mitte} y={mitte - 4} textAnchor="middle"
className={`donut-value${zentralBudget ? ` budget-${zentralBudget}` : ""}`}
style={{ fontSize: wertGroesse }}> style={{ fontSize: wertGroesse }}>
{zentralText} {zentralText}
</text> </text>
@@ -60,7 +67,9 @@ export default function Donut({ segmente = [], gesamtLabel = "gesamt", fmt = (v)
<li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}> <li key={b.name} onMouseEnter={() => setAktiv(b)} onMouseLeave={() => setAktiv(null)}>
<span className="swatch" style={{ background: b.color }} /> <span className="swatch" style={{ background: b.color }} />
<span className="legend-name">{b.name}</span> <span className="legend-name">{b.name}</span>
<span className="legend-value">{fmt(b.value)} · {Math.round(b.anteil * 100)} %</span> <span className={`legend-value${b.budget ? ` budget-${b.budget}` : ""}`}>
{fmt(b.value)} · {Math.round(b.anteil * 100)} %
</span>
</li> </li>
))} ))}
</ul> </ul>
+14
View File
@@ -1443,3 +1443,17 @@ tr.row-dup {
padding-top: var(--sp-2); padding-top: var(--sp-2);
border-top: 1px dashed var(--border); border-top: 1px dashed var(--border);
} }
/* Budget-Stand im Ringdiagramm: grün im Rahmen, rot darüber – in der Legende
und in der Mitte. Ohne Plan bleibt beides, wie es war: Da gibt es nichts zu
überschreiten, und eine Farbe behauptete etwas. */
.legend-value.budget-ok,
.donut-value.budget-ok {
fill: var(--ok);
color: var(--ok);
}
.legend-value.budget-over,
.donut-value.budget-over {
fill: var(--danger);
color: var(--danger);
}