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:
+1
-1
@@ -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 (1–2 Tage)
|
**Aufwand:** S (1–2 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.
|
||||||
|
|||||||
@@ -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
@@ -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')}.`;
|
||||||
|
|||||||
@@ -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)
|
||||||
* -------------------------------------------------------- */
|
* -------------------------------------------------------- */
|
||||||
|
|||||||
Reference in New Issue
Block a user