diff --git a/DEVELOPMENT_LOG.md b/DEVELOPMENT_LOG.md
index 51af7cd..08d58e1 100644
--- a/DEVELOPMENT_LOG.md
+++ b/DEVELOPMENT_LOG.md
@@ -6,6 +6,32 @@
---
+### v0.21.0 — 3-Month Trend Indicator
+**Status:** 🔄 IN PROGRESS
+**Date:** 2026-05-10
+**Priority:** MEDIUM
+
+| Agent | Status | Time | Notes |
+|-------|--------|------|-------|
+| Neo | ✅ COMPLETED | 19m | Backend trend calculation, TrendIndicator + TrendCard components |
+| Ripley | ✅ COMPLETED | — | Fixed duplicate TrendIndicator build error, version bump 0.20.9 → 0.21.0 |
+| Bishop | ⏳ PENDING | — | Verification |
+| Hudson | ⏳ PENDING | — | Security audit |
+
+**Files modified:** `routes/tracker.js`, `client/pages/TrackerPage.jsx`, `client/lib/version.js`, `package.json`
+
+**Work Completed:**
+- [x] Backend: 3-month trend calculation with year-wrapping
+- [x] Backend: trend object in API response (direction, percent_change, 3_month_avg)
+- [x] Frontend: TrendIndicator component (arrow + percentage + label)
+- [x] Frontend: TrendCard component (purple gradient card)
+- [x] Bug fix: removed duplicate TrendIndicator definition
+- [x] Version bumped to 0.21.0
+
+**Security Audit (Hudson):** Pending
+
+---
+
### v0.20.9 — Previous Month Paid on Tracker
**Status:** 🔄 IN PROGRESS
**Date:** 2026-05-10
diff --git a/FUTURE.md b/FUTURE.md
index ee6ac71..5b11f34 100644
--- a/FUTURE.md
+++ b/FUTURE.md
@@ -3,7 +3,7 @@
**This document tracks potential future enhancements for Bill Tracker.**
**Last Updated:** 2026-05-10
-**Current Version:** v0.20.9
+**Current Version:** v0.21.0
## How to Use This Document
@@ -39,25 +39,6 @@ Items are grouped under their priority section heading (`## 🔴 CRITICAL`, `##
### 🟡 MEDIUM
-### 3-Month Trend Indicator with Up/Down Arrows
-**Priority:** MEDIUM
-**Added:** 2026-05-08 by _null
-
-**Description:**
-Add trend indicators showing whether the last 3 months of payments went up or down compared to current month. Display as up/down arrow with percentage change.
-
-**Rationale:**
-Visual trend indicator helps users identify spending patterns without navigating to Analytics page.
-
-**Implementation Notes:**
-- Calculate 3-month rolling average
-- Compare current month vs. previous 3-month average
-- Show green up arrow if trending up (more paid), red down arrow if trending down
-- Display percentage change
-- Position in Tracker header or Summary card
-- Files likely to be modified: `routes/analytics.js` (new endpoint), `client/pages/TrackerPage.jsx` or `client/pages/SummaryPage.jsx`
-- Estimated effort: 4 hours
-
### Add loading skeletons and better async state management
**Priority:** MEDIUM
**Added:** 2026-05-08 by Scarlett
diff --git a/HISTORY.md b/HISTORY.md
index 038ac30..26bc48d 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,5 +1,12 @@
# Bill Tracker — Changelog
+## v0.21.0
+
+### Added
+- **3-Month Trend Indicator** — Tracker shows up/down/flat trend vs 3-month average with percentage change (↑ green, ↓ red, → gray)
+- Trend card with purple gradient header and TrendingUp icon
+- Backend: 3-month payment aggregation with year-wrapping, ±2% threshold for "flat"
+
## v0.20.9
### Added
diff --git a/client/lib/version.js b/client/lib/version.js
index d8e9d8d..50049d5 100644
--- a/client/lib/version.js
+++ b/client/lib/version.js
@@ -1,10 +1,10 @@
-export const APP_VERSION = '0.20.9';
+export const APP_VERSION = '0.21.0';
export const APP_NAME = 'BillTracker';
export const RELEASE_NOTES = {
- version: '0.20.9',
+ version: '0.21.0',
date: '2026-05-10',
highlights: [
- { icon: '📊', title: 'Previous Month Paid', desc: '"Last Month" column on Tracker shows last month\'s paid amount for comparison.' },
+ { icon: '📈', title: '3-Month Trend Indicator', desc: 'Tracker shows up/down trend vs 3-month average with percentage change.' },
],
};
\ No newline at end of file
diff --git a/client/pages/TrackerPage.jsx b/client/pages/TrackerPage.jsx
index 96657c4..2db174a 100644
--- a/client/pages/TrackerPage.jsx
+++ b/client/pages/TrackerPage.jsx
@@ -96,6 +96,41 @@ const CARD_DEFS = {
},
};
+function TrendIndicator({ trend }) {
+ if (!trend) return null;
+
+ const { direction, percent_change } = trend;
+
+ let icon, color, text;
+ switch (direction) {
+ case 'up':
+ icon = '↑';
+ color = 'text-emerald-500';
+ text = `${icon} ${percent_change}%`;
+ break;
+ case 'down':
+ icon = '↓';
+ color = 'text-red-500';
+ text = `${icon} ${Math.abs(percent_change)}%`;
+ break;
+ default:
+ icon = '→';
+ color = 'text-muted-foreground';
+ text = `${icon} ${percent_change}%`;
+ }
+
+ return (
+
+
+ {text}
+
+
+ vs 3-mo avg
+
+
+ );
+}
+
function SummaryCard({ type, value, onEdit, hint }) {
const def = CARD_DEFS[type];
const isActive = def.activateWhen(value || 0);
@@ -140,6 +175,25 @@ function SummaryCard({ type, value, onEdit, hint }) {
);
}
+function TrendCard({ trend }) {
+ if (!trend) return null;
+
+ return (
+
+ );
+}
+
// ── Status badge ───────────────────────────────────────────────────────────
const StatusBadge = React.memo(function StatusBadge({ status, clickable, onClick, loading }) {
const meta = useMemo(() => STATUS_META[status] || STATUS_META.upcoming, [status]);
@@ -1356,6 +1410,7 @@ export default function TrackerPage() {
+ {summary.trend && }
{/* ── Empty state ── */}
diff --git a/package.json b/package.json
index 31afb6b..664986e 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "bill-tracker",
- "version": "0.20.9",
+ "version": "0.21.0",
"description": "Monthly bill tracking system",
"main": "server.js",
"scripts": {
diff --git a/routes/tracker.js b/routes/tracker.js
index b2d6423..9febed6 100644
--- a/routes/tracker.js
+++ b/routes/tracker.js
@@ -24,6 +24,13 @@ router.get('/', (req, res) => {
const prevYear = month === 1 ? year - 1 : year;
const prevMonthRange = getCycleRange(prevYear, prevMonth);
+ // Calculate 3-month range for trend analysis
+ const threeMonthsAgo = (() => {
+ let y = year, m = month - 2;
+ while (m <= 0) { m += 12; y -= 1; }
+ return { year: y, month: m };
+ })();
+
const bills = db.prepare(`
SELECT b.*, c.name AS category_name
FROM bills b
@@ -44,6 +51,15 @@ router.get('/', (req, res) => {
AND deleted_at IS NULL
`);
+ // Prepare statement for 3-month trend calculations
+ const threeMonthPaymentsStmt = db.prepare(`
+ SELECT SUM(amount) as total_paid, strftime('%Y-%m', paid_date) as month_key
+ FROM payments
+ WHERE bill_id = ? AND paid_date BETWEEN ? AND ?
+ AND deleted_at IS NULL
+ GROUP BY strftime('%Y-%m', paid_date)
+ `);
+
const rows = bills.map(bill => {
// Only count non-deleted payments for status/totals
const payments = db.prepare(`
@@ -89,6 +105,67 @@ router.get('/', (req, res) => {
// Calculate previous month total
const previousMonthTotal = activeRows.reduce((s, r) => s + r.previous_month_paid, 0);
+ // Calculate 3-month trend data
+ const threeMonthStart = getCycleRange(threeMonthsAgo.year, threeMonthsAgo.month).start;
+ const currentMonthEnd = end;
+
+ // Get all payments for the last 3 months for this user
+ // Join through bills to get user_id since payments table doesn't have user_id
+ const threeMonthPayments = db.prepare(`
+ SELECT SUM(p.amount) as total_paid, strftime('%Y-%m', p.paid_date) as month_key
+ FROM payments p
+ JOIN bills b ON p.bill_id = b.id
+ WHERE b.user_id = ? AND p.paid_date BETWEEN ? AND ?
+ AND p.deleted_at IS NULL
+ GROUP BY strftime('%Y-%m', p.paid_date)
+ `).all(req.user.id, threeMonthStart, currentMonthEnd);
+
+ // Create a map of month payments for easier access
+ const monthlyPaymentsMap = new Map();
+ threeMonthPayments.forEach(payment => {
+ monthlyPaymentsMap.set(payment.month_key, payment.total_paid);
+ });
+
+ // Calculate payments for each of the last 3 months
+ const months = [];
+ for (let i = 2; i >= 0; i--) {
+ const date = new Date(year, month - 1 - i);
+ const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
+ months.push({
+ year: date.getFullYear(),
+ month: date.getMonth() + 1,
+ key: monthKey,
+ payment: parseFloat(monthlyPaymentsMap.get(monthKey) || 0)
+ });
+ }
+
+ // Calculate 3-month average
+ const threeMonthTotal = months.reduce((sum, m) => sum + m.payment, 0);
+ const threeMonthAvg = threeMonthTotal / 3;
+
+ // Calculate current month paid (sum of all bills)
+ const currentMonthPaid = activeTotalPaid;
+
+ // Calculate percentage change
+ let percentChange = 0;
+ let direction = 'flat';
+
+ if (threeMonthAvg > 0) {
+ percentChange = ((currentMonthPaid - threeMonthAvg) / threeMonthAvg) * 100;
+
+ // Determine direction based on percentage change
+ if (percentChange > 2) {
+ direction = 'up';
+ } else if (percentChange < -2) {
+ direction = 'down';
+ } else {
+ direction = 'flat';
+ }
+ }
+
+ // Ensure percentChange is a number with 1 decimal place
+ percentChange = parseFloat(percentChange.toFixed(1));
+
res.json({
year, month, today: todayStr,
summary: {
@@ -103,6 +180,12 @@ router.get('/', (req, res) => {
count_late: activeRows.filter(r => r.status === 'late' || r.status === 'missed').length,
count_autodraft: activeRows.filter(r => r.status === 'autodraft').length,
previous_month_total: previousMonthTotal,
+ trend: {
+ three_month_avg: parseFloat(threeMonthAvg.toFixed(2)),
+ current_month_paid: parseFloat(currentMonthPaid.toFixed(2)),
+ percent_change: percentChange,
+ direction: direction
+ }
},
rows,
});