feat: Eine Ausgabe, die kein Verlust ist

Sparen zaehlt im Budget wie eine Ausgabe – zu Recht, das Geld ist diesen Monat
gebunden. Es stand danach aber in Rot da, und wer tausend zuruecklegte statt
achthundert, bekam eine Warnung fuer etwas Gutes.

Ein Haken an der Kategorie stellt das ab. Zwei Folgen:

Der Betrag steht neutral. NICHT gruen: Gruen heisst in Certain ueberall "Geld
kommt rein", und CHF 1'010 in Gruen liest sich als Einnahme. Grau sagt genau
das Richtige – kein Verlust, kein Gewinn.

Und mehr als geplant ist keine Ueberschreitung mehr. Das gilt auch eine Ebene
hoeher: Eine Gruppe misst sich an dem, was WIRKLICH zu viel war. Sonst faerbt
sich "Finanzen" rot, weil jemand gespart hat, und die echte Ueberschreitung
daneben faellt nicht mehr auf. Angezeigt werden weiter die vollen Summen; nur
das Urteil laesst das Sparen aus.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Scarriffle
2026-09-07 15:28:36 +02:00
co-authored by Claude Opus 5
parent 15c2a164ec
commit ff621984dc
12 changed files with 210 additions and 21 deletions
+19 -6
View File
@@ -55,6 +55,20 @@ function AccountBalances() {
);
}
// Ist das Ueberschreiten dieser Zeile eine Warnung? Bei Sparen und Ruecklagen
// nicht: Wer mehr zurueckgelegt hat als geplant, hat nichts ueberzogen.
function warnt(r) {
return r.over && !r.good;
}
// Farbe des Betrags. "good" schlaegt beides: nicht rot (keine Warnung), aber
// auch nicht gruen – gruen heisst hier ueberall "Geld kommt rein".
function tonFuer(r) {
if (r.good) return "muted";
if (!(r.planned > 0)) return "muted";
return r.over ? "neg" : "pos";
}
function BudgetDonut({ props }) {
const { t } = useTranslation();
const groupBy = props?.group_by || "group";
@@ -94,11 +108,11 @@ function BudgetProgress({ props }) {
<div className="dash-progress-top">
<span className="ellipsis" style={{ color: r.color }}>●</span>
<span className="grow ellipsis">{r.name}</span>
<span className={`amount small ${r.over ? "neg" : "muted"}`}>{formatMoney(r.actual, data.currency)}</span>
<span className={`amount small ${warnt(r) ? "neg" : "muted"}`}>{formatMoney(r.actual, data.currency)}</span>
<span className="muted small"> / {formatMoney(r.planned, data.currency)}</span>
</div>
<div className="budget-bar">
<div className={`budget-bar-fill${r.over ? " over" : ""}`} style={{ width: `${pct}%` }} />
<div className={`budget-bar-fill${warnt(r) ? " over" : ""}`} style={{ width: `${pct}%` }} />
</div>
</div>
);
@@ -239,7 +253,7 @@ function ExpenseShare({ props }) {
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,
budget: r.planned > 0 && !r.good ? (r.over ? "over" : "ok") : null,
}))}
gesamtLabel={t("cards.spent")}
gesamtBudget={data.total_planned > 0 ? (data.total_over ? "over" : "ok") : null}
@@ -271,8 +285,7 @@ function ExpenseShare({ props }) {
{/* Grün im geplanten Rahmen, rot darüber – dieselben Farben wie
überall sonst für Einnahme und Ausgabe. Ohne Plan bleibt der
Betrag gedeckt: Da gibt es nichts zu überschreiten. */}
<span className={`amount small share-amount ${
r.planned > 0 ? (r.over ? "neg" : "pos") : "muted"}`}>
<span className={`amount small share-amount ${tonFuer(r)}`}>
{formatMoney(r.amount, data.currency)}
</span>
</div>
@@ -280,7 +293,7 @@ function ExpenseShare({ props }) {
{/* Der Balken IST die Ausgabe – er trägt die Aussage mit, damit
man sie sieht, ohne die Zahl zu lesen. Ohne Plan bleibt die
Kategoriefarbe: Da gibt es nichts zu überschreiten. */}
<div className={`budget-bar-fill${r.planned > 0 && r.over ? " over" : ""}`}
<div className={`budget-bar-fill${r.planned > 0 && warnt(r) ? " over" : ""}`}
style={{ width: `${pct}%`,
...(r.planned > 0 ? {} : { background: r.color }) }} />
</div>
+2
View File
@@ -246,6 +246,8 @@
"searchPlaceholder": "Kategorie suchen …",
"excludedFromStats": "Nicht in Auswertungen einrechnen",
"excludedHint": "Für Umbuchungen zwischen eigenen Konten: Der Kontostand ändert sich, aber Budget, Dashboard und Statistik lassen diese Buchungen außen vor – das Geld ist ja nicht ausgegeben.",
"spendingIsGood": "Diese Ausgabe ist kein Verlust",
"spendingIsGoodHint": "Für Sparen, Rücklagen und Vorsorge: Der Betrag zählt weiter im Budget – das Geld ist diesen Monat gebunden –, steht aber neutral statt rot. Und mehr zurückzulegen als geplant gilt nicht als Überschreitung.",
"excludedShort": "neutral",
"applyRules": "Regeln nachträglich anwenden",
"applyRulesHint": "Regeln greifen beim Import. Alles, was vorher hereinkam – oder bevor es die Regel gab –, liegt noch ohne Kategorie da. Hier holst du das nach.",
+2
View File
@@ -246,6 +246,8 @@
"searchPlaceholder": "Search category …",
"excludedFromStats": "Exclude from reports",
"excludedHint": "For transfers between your own accounts: the balance still changes, but budget, dashboard and statistics leave these out – the money was not spent.",
"spendingIsGood": "This spending is not a loss",
"spendingIsGoodHint": "For savings and reserves: the amount still counts towards the budget – the money is committed this month – but it is shown neutrally instead of red, and putting aside more than planned is not treated as overspending.",
"excludedShort": "neutral",
"applyRules": "Apply rules to existing transactions",
"applyRulesHint": "Rules apply on import. Anything that came in earlier – or before the rule existed – still sits there without a category. This catches up.",
+9 -6
View File
@@ -10,12 +10,15 @@ import Modal from "../components/Modal";
import EditableAmount from "../components/EditableAmount";
import { formatMoney, formatMonthLabel } from "../format";
function ProgressBar({ actual, planned }) {
function ProgressBar({ actual, planned, over }) {
const pct = planned > 0 ? Math.min(actual / planned, 1) * 100 : actual > 0 ? 100 : 0;
const over = actual > planned;
// Ob ueberschritten wurde, sagt der Server – er laesst dabei das Sparen aus,
// und das ist eine Rechnung, die hier niemand nachbauen sollte. Ohne Angabe
// (andere Aufrufer) bleibt es beim schlichten Vergleich.
const rot = over ?? actual > planned;
return (
<div className="budget-bar">
<div className={`budget-bar-fill${over ? " over" : ""}`} style={{ width: `${pct}%` }} />
<div className={`budget-bar-fill${rot ? " over" : ""}`} style={{ width: `${pct}%` }} />
</div>
);
}
@@ -158,15 +161,15 @@ export default function Budget() {
<div className="budget-group-head">
<IconChip name={g.icon} color={g.color} size={22} />
<strong className="grow ellipsis">{g.name}</strong>
<span className={`amount small ${g.over ? "neg" : "muted"}`}>{fmt(g.actual_cents)}</span>
<span className={`amount small ${g.over && !g.good ? "neg" : "muted"}`}>{fmt(g.actual_cents)}</span>
<span className="muted small"> / {fmt(g.planned_cents)}</span>
</div>
<ProgressBar actual={g.actual_cents} planned={g.planned_cents} />
<ProgressBar actual={g.actual_cents} planned={g.planned_cents} over={g.over} />
{g.categories.map((c) => (
<div className="budget-crow" key={c.category_id}>
<IconChip name={c.icon} color={c.color} size={20} />
<span className="grow ellipsis">{c.name}</span>
<span className={`amount small ${c.over ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
<span className={`amount small ${c.over && !c.good ? "neg" : "muted"}`}>{fmt(c.actual_cents)}</span>
<EditableAmount cents={c.planned_cents} onSave={(cents) => saveMonth(v.label, c.category_id, cents)} />
</div>
))}
+12 -3
View File
@@ -197,8 +197,10 @@ export default function Categories() {
groupId,
form: editing
? { name: editing.name, icon: editing.icon, color: editing.color,
excluded_from_stats: !!editing.excluded_from_stats }
: { name: "", icon: "more-horizontal", color: "#6b7480", excluded_from_stats: false },
excluded_from_stats: !!editing.excluded_from_stats,
spending_is_good: !!editing.spending_is_good }
: { name: "", icon: "more-horizontal", color: "#6b7480",
excluded_from_stats: false, spending_is_good: false },
});
}
@@ -211,7 +213,8 @@ export default function Categories() {
else await api.createGroup({ name: form.name.trim(), kind: form.kind, icon: form.icon, color: form.color });
} else {
const rumpf = { name: form.name.trim(), icon: form.icon, color: form.color,
excluded_from_stats: !!form.excluded_from_stats };
excluded_from_stats: !!form.excluded_from_stats,
spending_is_good: !!form.spending_is_good };
if (editing) await api.updateCategory(editing.id, rumpf);
else await api.createCategory({ group_id: groupId, ...rumpf });
}
@@ -339,6 +342,12 @@ export default function Categories() {
<span>{t("categories.excludedFromStats")}</span>
</label>
<p className="muted small" style={{ marginTop: -6 }}>{t("categories.excludedHint")}</p>
<label className="checkline">
<input type="checkbox" checked={!!modal.form.spending_is_good}
onChange={(e) => setModal({ ...modal, form: { ...modal.form, spending_is_good: e.target.checked } })} />
<span>{t("categories.spendingIsGood")}</span>
</label>
<p className="muted small" style={{ marginTop: -6 }}>{t("categories.spendingIsGoodHint")}</p>
</>
)}
<label>