Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 38 additions & 16 deletions src/app/reports/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,10 +229,12 @@ function ExpenseDonutChart({

// Curated color mapping
const slices: DonutSliceData[] = useMemo(() => {
return items.map((it, idx) => ({
...it,
color: it.color || DONUT_PALETTE[idx % DONUT_PALETTE.length],
}));
return (items || [])
.filter(it => (it.total || 0) > 0)
.map((it, idx) => ({
...it,
color: it.color || DONUT_PALETTE[idx % DONUT_PALETTE.length],
}));
}, [items]);

const totalSum = useMemo(() => {
Expand Down Expand Up @@ -273,9 +275,9 @@ function ExpenseDonutChart({
`;
}

const gap = slices.length > 1 ? Math.min(1.5, sweep * 0.1) : 0;
const gap = slices.length > 1 ? Math.min(1.5, Math.max(0, sweep * 0.1)) : 0;
const actualStart = startAngle + gap / 2;
const actualEnd = endAngle - gap / 2;
const actualEnd = Math.max(actualStart + 0.05, endAngle - gap / 2);

const x1 = cx + rOut * Math.cos(toRad(actualStart));
const y1 = cy + rOut * Math.sin(toRad(actualStart));
Expand Down Expand Up @@ -469,14 +471,34 @@ export default function ReportsPage() {
);
}

const spendingByCategory = data?.spendingByCategory || [];
const spendingByTag = data?.spendingByTag || [];
const spendingByMerchant = data?.spendingByMerchant || [];
const periods = forecast?.periods || [];
const dailySpending = data?.dailySpending?.days || (Array.isArray(data?.dailySpending) ? data?.dailySpending : []);
const budgetHealth = data?.budgetHealth;
const comparison = data?.comparison;
const metrics = data?.metrics || {};
if (data?.error) {
return (
<div className="card" style={{ padding: '2.5rem', textAlign: 'center', color: 'var(--color-expense)' }}>
<p style={{ fontWeight: 600, fontSize: '1.1rem', marginBottom: '0.5rem' }}>Failed to load reports data</p>
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>{data.error}</p>
<button
onClick={() => setSelectedMonth(currentMonthStr)}
className="btn-secondary"
style={{ marginTop: '1rem' }}
>
Reset to Current Month
</button>
</div>
);
}

const spendingByCategory = Array.isArray(data?.spendingByCategory) ? data.spendingByCategory : [];
const spendingByTag = Array.isArray(data?.spendingByTag) ? data.spendingByTag : [];
const spendingByMerchant = Array.isArray(data?.spendingByMerchant) ? data.spendingByMerchant : [];
const periods = Array.isArray(forecast?.periods) ? forecast.periods : [];
const dailySpending = Array.isArray(data?.dailySpending?.days)
? data.dailySpending.days
: Array.isArray(data?.dailySpending)
? data.dailySpending
: [];
const budgetHealth = data?.budgetHealth && typeof data.budgetHealth === 'object' ? data.budgetHealth : null;
const comparison = data?.comparison && typeof data.comparison === 'object' ? data.comparison : null;
const metrics = (data?.metrics && typeof data.metrics === 'object') ? data.metrics : {};

// Fix: use correct field names from getDashboardMetrics
const totalMonthSpend = metrics.monthlyExpenses || 0;
Expand All @@ -487,7 +509,7 @@ export default function ReportsPage() {
const isCurrentMonth = selYear === today.getFullYear() && selMon === (today.getMonth() + 1);
const elapsedDays = isCurrentMonth ? today.getDate() : daysInMonth;
const avgDailySpend = elapsedDays > 0 ? Math.round(totalMonthSpend / elapsedDays) : 0;
const maxDailySpend = Math.max(...dailySpending.map((d: any) => d.dailySpent ?? d.amount ?? 0), 100);
const maxDailySpend = dailySpending.length > 0 ? Math.max(...dailySpending.map((d: any) => d.dailySpent ?? d.amount ?? 0), 100) : 100;

// Savings rate for speedometer
const savingsRate = totalMonthIncome > 0 ? Math.round(((totalMonthIncome - totalMonthSpend) / totalMonthIncome) * 100) : 0;
Expand Down Expand Up @@ -835,7 +857,7 @@ export default function ReportsPage() {
spent={d.dailySpent ?? d.amount ?? 0}
cumulative={d.cumulativeSpent ?? d.cumulative ?? 0}
maxSpend={maxDailySpend}
avgSpend={avgDailySpend * 100}
avgSpend={avgDailySpend}
/>
))}
</div>
Expand Down
8 changes: 5 additions & 3 deletions src/lib/finance-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2136,7 +2136,7 @@ export class FinanceService {
AND t.type = 'expense'
AND t.is_deleted = 0
AND t.date LIKE ?
GROUP BY COALESCE(c.id, 'uncategorized')
GROUP BY COALESCE(c.id, 'uncategorized'), c.name, c.icon, c.color
ORDER BY total DESC
`).all(userId, `${month}%`) as any[];

Expand All @@ -2159,7 +2159,7 @@ export class FinanceService {
AND t.type = 'expense'
AND t.is_deleted = 0
AND t.date LIKE ?
GROUP BY tg.id
GROUP BY tg.id, tg.name, tg.color
ORDER BY total DESC
`).all(userId, `${month}%`) as any[];

Expand Down Expand Up @@ -2266,7 +2266,9 @@ export class FinanceService {

const dayMap: Record<number, number> = {};
for (const r of rows) {
dayMap[r.day] = r.total;
if (r.day && r.day >= 1 && r.day <= daysInMonth) {
dayMap[r.day] = r.total;
}
}

let runningCumulative = 0;
Expand Down
74 changes: 74 additions & 0 deletions tests/finance-service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,80 @@ describe('Finance Domain Business Logic Tests', () => {
const deleted = service.getAccountById(acc.id, userId);
expect(deleted).toBeNull();
});

it('correctly aggregates spending by category, tag, and daily burn for reports', () => {
const acc = service.createAccount({
userId,
name: 'HDFC Bank',
type: 'savings',
openingBalance: 10000000,
})!;

const cat = db.prepare('SELECT id, name FROM categories WHERE user_id = ? LIMIT 1').get(userId) as any;

// 1. Transaction with category and tag
service.createTransaction({
userId,
accountId: acc.id,
type: 'expense',
amount: 60000, // ₹600
date: '2026-03-05',
categoryId: cat.id,
tags: ['groceries'],
});

// 2. Transaction with another tag
service.createTransaction({
userId,
accountId: acc.id,
type: 'expense',
amount: 40000, // ₹400
date: '2026-03-12',
categoryId: cat.id,
tags: ['entertainment'],
});

// 3. Uncategorized and untagged transaction
service.createTransaction({
userId,
accountId: acc.id,
type: 'expense',
amount: 20000, // ₹200
date: '2026-03-20',
});

// Total expense = 600 + 400 + 200 = 1200 (120000 paise)

// Test getSpendingByCategory
const byCategory = service.getSpendingByCategory(userId, '2026-03');
expect(byCategory.length).toBe(2); // Known category + Uncategorized
const knownCat = byCategory.find(c => c.id === cat.id);
expect(knownCat?.total).toBe(100000); // 1000.00
expect(knownCat?.percentage).toBe(83); // 100000 / 120000 = 83.33% -> 83%

const uncat = byCategory.find(c => c.id === 'uncategorized');
expect(uncat?.total).toBe(20000);
expect(uncat?.percentage).toBe(17); // 20000 / 120000 = 16.66% -> 17%

// Test getSpendingByTag
const byTag = service.getSpendingByTag(userId, '2026-03');
expect(byTag.length).toBe(2);
const groceriesTag = byTag.find(t => t.name === 'groceries');
expect(groceriesTag?.total).toBe(60000);
expect(groceriesTag?.percentage).toBe(60); // 60000 / (60000 + 40000) = 60% of tagged

// Test getDailySpending
const daily = service.getDailySpending(userId, '2026-03');
expect(daily.days.length).toBe(31);
expect(daily.totalSpent).toBe(120000);
const day5 = daily.days.find(d => d.day === 5);
expect(day5?.dailySpent).toBe(60000);
expect(day5?.cumulativeSpent).toBe(60000);

const day12 = daily.days.find(d => d.day === 12);
expect(day12?.dailySpent).toBe(40000);
expect(day12?.cumulativeSpent).toBe(100000);
});
});


Loading