feat(budget): add month-over-month comparison to summary cards

Each summary card (Einnahmen, Ausgaben, Saldo) now shows a trend line
comparing the current month to the previous one. The previous month's
summary is fetched in parallel via the existing /budget/summary endpoint,
so there is no extra round-trip latency. Positive deltas render in green
(▲), negative in red (▼), unchanged in neutral grey (—).

Closes BL-02.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Ulas
2026-03-30 22:54:04 +02:00
parent ca377e8441
commit 26d3d12a22
4 changed files with 55 additions and 12 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ Das Datenmodell speichert `recurrence_rule` (iCal RRULE) für Kalender-Events. D
### BL-02 — Budget: Monatsvergleich (aktuell vs. Vormonat) ### BL-02 — Budget: Monatsvergleich (aktuell vs. Vormonat)
**Status:** Offen **Status:** Erledigt (v0.3.0)
**Aufwand:** S (12 Tage) **Aufwand:** S (12 Tage)
SPEC: „Monatsvergleich (aktuell vs. Vormonat)". Derzeit zeigt die Budget-Seite nur den aktuellen Monat. Es fehlen API-Endpunkt und UI-Komponente für den Vergleich. SPEC: „Monatsvergleich (aktuell vs. Vormonat)". Derzeit zeigt die Budget-Seite nur den aktuellen Monat. Es fehlen API-Endpunkt und UI-Komponente für den Vergleich.
+3
View File
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added
- Budget: month-over-month comparison in summary cards — each card (Einnahmen, Ausgaben, Saldo) shows a trend line (▲/▼ + delta amount vs. previous month); previous month summary is fetched in parallel with current month
## [0.2.1] - 2026-03-30 ## [0.2.1] - 2026-03-30
### Fixed ### Fixed
+43 -11
View File
@@ -26,9 +26,10 @@ const MONTH_NAMES = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
// -------------------------------------------------------- // --------------------------------------------------------
let state = { let state = {
month: '', // YYYY-MM month: '', // YYYY-MM
entries: [], entries: [],
summary: null, summary: null,
prevSummary: null, // Vormonat für Monatsvergleich
}; };
let _container = null; let _container = null;
@@ -56,19 +57,23 @@ function addMonths(ym, n) {
// -------------------------------------------------------- // --------------------------------------------------------
async function loadMonth(month) { async function loadMonth(month) {
const prevMonth = addMonths(month, -1);
try { try {
const [entriesRes, summaryRes] = await Promise.all([ const [entriesRes, summaryRes, prevSummaryRes] = await Promise.all([
api.get(`/budget?month=${month}`), api.get(`/budget?month=${month}`),
api.get(`/budget/summary?month=${month}`), api.get(`/budget/summary?month=${month}`),
api.get(`/budget/summary?month=${prevMonth}`),
]); ]);
state.month = month; state.month = month;
state.entries = entriesRes.data; state.entries = entriesRes.data;
state.summary = summaryRes.data; state.summary = summaryRes.data;
state.prevSummary = prevSummaryRes.data;
} catch (err) { } catch (err) {
console.error('[Budget] loadMonth Fehler:', err); console.error('[Budget] loadMonth Fehler:', err);
state.month = month; state.month = month;
state.entries = []; state.entries = [];
state.summary = { income: 0, expenses: 0, balance: 0, by_category: [] }; state.summary = { income: 0, expenses: 0, balance: 0, byCategory: [] };
state.prevSummary = null;
window.oikos?.showToast('Budget konnte nicht geladen werden.', 'danger'); window.oikos?.showToast('Budget konnte nicht geladen werden.', 'danger');
} }
} }
@@ -157,8 +162,10 @@ function renderBody() {
if (!body) return; if (!body) return;
updateLabel(); updateLabel();
const s = state.summary; const s = state.summary;
const p = state.prevSummary;
const balanceClass = s.balance >= 0 ? 'budget-summary-card--balance-positive' : 'budget-summary-card--balance-negative'; const balanceClass = s.balance >= 0 ? 'budget-summary-card--balance-positive' : 'budget-summary-card--balance-negative';
const prevLabel = p ? formatMonthLabel(p.month).split(' ')[0].slice(0, 3) : '';
body.innerHTML = ` body.innerHTML = `
<!-- Zusammenfassung --> <!-- Zusammenfassung -->
@@ -166,14 +173,17 @@ function renderBody() {
<div class="budget-summary-card budget-summary-card--income"> <div class="budget-summary-card budget-summary-card--income">
<div class="budget-summary-card__label">Einnahmen</div> <div class="budget-summary-card__label">Einnahmen</div>
<div class="budget-summary-card__amount">${formatAmount(s.income)}</div> <div class="budget-summary-card__amount">${formatAmount(s.income)}</div>
${p ? renderTrend(s.income, p.income, prevLabel) : ''}
</div> </div>
<div class="budget-summary-card budget-summary-card--expenses"> <div class="budget-summary-card budget-summary-card--expenses">
<div class="budget-summary-card__label">Ausgaben</div> <div class="budget-summary-card__label">Ausgaben</div>
<div class="budget-summary-card__amount">${formatAmount(Math.abs(s.expenses))}</div> <div class="budget-summary-card__amount">${formatAmount(Math.abs(s.expenses))}</div>
${p ? renderTrend(s.expenses, p.expenses, prevLabel) : ''}
</div> </div>
<div class="budget-summary-card ${balanceClass}"> <div class="budget-summary-card ${balanceClass}">
<div class="budget-summary-card__label">Saldo</div> <div class="budget-summary-card__label">Saldo</div>
<div class="budget-summary-card__amount">${formatAmount(s.balance)}</div> <div class="budget-summary-card__amount">${formatAmount(s.balance)}</div>
${p ? renderTrend(s.balance, p.balance, prevLabel) : ''}
</div> </div>
</div> </div>
@@ -274,6 +284,28 @@ function renderEntries() {
}).join(''); }).join('');
} }
/**
* Rendert eine Trend-Zeile im Vergleich zum Vormonat.
* Alle drei Metriken (income, expenses, balance) nutzen dieselbe Logik:
* delta > 0 → positiver Trend (▲ grün), delta < 0 → negativer Trend (▼ rot).
* Ausgaben werden als negative Zahlen übergeben, daher gilt:
* weniger Ausgaben ↔ delta > 0 ↔ gut.
* @param {number} current Aktueller Wert
* @param {number} prev Vormonatswert
* @param {string} prevLabel Kurzname des Vormonats (z.B. "Mär")
*/
function renderTrend(current, prev, prevLabel) {
const delta = current - prev;
if (Math.abs(delta) < 0.005) {
return `<div class="budget-summary-card__trend budget-summary-card__trend--neutral">— wie ${prevLabel}</div>`;
}
const positive = delta > 0;
const arrow = positive ? '▲' : '▼';
const sign = positive ? '+' : '';
const cls = positive ? 'budget-summary-card__trend--positive' : 'budget-summary-card__trend--negative';
return `<div class="budget-summary-card__trend ${cls}">${arrow} ${sign}${formatAmount(delta)} vs. ${prevLabel}</div>`;
}
function formatEntryDate(dateStr) { function formatEntryDate(dateStr) {
const d = new Date(dateStr + 'T00:00:00'); const d = new Date(dateStr + 'T00:00:00');
return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.`; return `${d.getDate().toString().padStart(2, '0')}.${(d.getMonth() + 1).toString().padStart(2, '0')}.`;
+8
View File
@@ -101,6 +101,14 @@
.budget-summary-card--balance-positive .budget-summary-card__amount { color: var(--color-success); } .budget-summary-card--balance-positive .budget-summary-card__amount { color: var(--color-success); }
.budget-summary-card--balance-negative .budget-summary-card__amount { color: var(--color-danger); } .budget-summary-card--balance-negative .budget-summary-card__amount { color: var(--color-danger); }
.budget-summary-card__trend {
font-size: var(--text-xs);
margin-top: var(--space-1);
}
.budget-summary-card__trend--positive { color: var(--color-success); }
.budget-summary-card__trend--negative { color: var(--color-danger); }
.budget-summary-card__trend--neutral { color: var(--color-text-secondary); }
/* -------------------------------------------------------- /* --------------------------------------------------------
* Kategorien-Diagramm (Canvas) * Kategorien-Diagramm (Canvas)
* -------------------------------------------------------- */ * -------------------------------------------------------- */