From a0c166ee6a1a55f8b848f36a05648f8c86006a7d Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Sun, 27 Sep 2026 13:04:09 +0530 Subject: [PATCH 1/6] =?UTF-8?q?feat(reports):=20analytics=20overhaul=20?= =?UTF-8?q?=E2=80=94=20tag/category=20toggle,=20speedometer=20gauges,=20bu?= =?UTF-8?q?dget=20health,=20interactive=20burn=20chart,=20NaN=20fix,=20CSV?= =?UTF-8?q?=20tags?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add category/tag toggle switch for expense breakdown analytics - Remove split transactions analytics section - Move 30/60/90 day forecast to end of page - Add 3 dynamic SVG speedometer gauges (savings rate, budget utilization, expense ratio) - Add Y-axis labels and hover tooltips with values to daily burn chart - Fix NaN/day average (metrics.totalExpense → metrics.monthlyExpenses) - Add budget health summary with over-budget/near-limit alerts - Add getSpendingByTag() and getBudgetHealthSummary() to finance service - Fix CSV export missing Tags column (now includes tag names) --- src/app/api/export/route.ts | 4 +- src/app/api/reports/route.ts | 6 +- src/app/reports/page.tsx | 622 ++++++++++++++++++++++++++--------- src/lib/finance-service.ts | 48 +++ 4 files changed, 522 insertions(+), 158 deletions(-) diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index a2a5046..9ca78ff 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -16,9 +16,10 @@ export async function GET(req: Request) { if (format === 'csv') { const txs = service.getTransactions(user.id, { limit: 10000 }); - let csv = 'Date,Type,Amount,Currency,Account,Category,Merchant,Notes\n'; + let csv = 'Date,Type,Amount,Currency,Account,Category,Merchant,Tags,Notes\n'; for (const t of txs as any[]) { const amt = (t.amount / 100).toFixed(2); + const tagStr = Array.isArray(t.tags) ? t.tags.join('; ') : ''; const line = [ t.date, t.type, @@ -27,6 +28,7 @@ export async function GET(req: Request) { `"${(t.account_name || '').replace(/"/g, '""')}"`, `"${(t.category_name || '').replace(/"/g, '""')}"`, `"${(t.merchant_name || '').replace(/"/g, '""')}"`, + `"${tagStr.replace(/"/g, '""')}"`, `"${(t.notes || '').replace(/"/g, '""')}"`, ].join(','); csv += line + '\n'; diff --git a/src/app/api/reports/route.ts b/src/app/api/reports/route.ts index 621dbfd..dec8a49 100644 --- a/src/app/api/reports/route.ts +++ b/src/app/api/reports/route.ts @@ -13,11 +13,12 @@ export async function GET(req: Request) { const service = new FinanceService(); const spendingByCategory = service.getSpendingByCategory(user.id, month); + const spendingByTag = service.getSpendingByTag(user.id, month); const spendingByMerchant = service.getSpendingByMerchant(user.id, 15); const cashFlowTrend = service.getMonthlyCashFlowTrend(user.id, 12); const metrics = service.getDashboardMetrics(user.id, month); const dailySpending = service.getDailySpending(user.id, month); - const splitAnalytics = service.getSplitTransactionsAnalytics(user.id, month); + const budgetHealth = service.getBudgetHealthSummary(user.id, month); let comparison = null; if (compareMonth) { @@ -27,11 +28,12 @@ export async function GET(req: Request) { return NextResponse.json({ selectedMonth: month, spendingByCategory, + spendingByTag, spendingByMerchant, cashFlowTrend, metrics, dailySpending, - splitAnalytics, + budgetHealth, comparison, }); } catch (error: any) { diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index 20e616c..cfa59e6 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useMemo, useRef } from 'react'; import { useApp } from '@/context/AppContext'; import { MoneyDisplay } from '@/components/MoneyDisplay'; import { formatDateDMY } from '@/lib/date'; @@ -17,11 +17,175 @@ import { ArrowRight, ArrowUpRight, ArrowDownRight, - Split, Flame, Scale, + Tag, + Gauge, + ShieldCheck, + ShieldAlert, + AlertTriangle, + CheckCircle2, } from 'lucide-react'; +/* ──── SVG Speedometer Gauge ──── */ +function SpeedometerGauge({ value, max, label, sublabel }: { value: number; max: number; label: string; sublabel?: string }) { + const clampedPercent = Math.min(100, Math.max(0, max > 0 ? (value / max) * 100 : 0)); + // Arc from 135° to 405° (270° sweep) + const startAngle = 135; + const sweepAngle = 270; + const endAngle = startAngle + sweepAngle; + const radius = 80; + const cx = 100; + const cy = 100; + + const toRad = (deg: number) => (deg * Math.PI) / 180; + const arcPath = (from: number, to: number) => { + const x1 = cx + radius * Math.cos(toRad(from)); + const y1 = cy + radius * Math.sin(toRad(from)); + const x2 = cx + radius * Math.cos(toRad(to)); + const y2 = cy + radius * Math.sin(toRad(to)); + const largeArc = to - from > 180 ? 1 : 0; + return `M ${x1} ${y1} A ${radius} ${radius} 0 ${largeArc} 1 ${x2} ${y2}`; + }; + + const needleAngle = startAngle + (clampedPercent / 100) * sweepAngle; + const needleLen = radius - 15; + const nx = cx + needleLen * Math.cos(toRad(needleAngle)); + const ny = cy + needleLen * Math.sin(toRad(needleAngle)); + + // Color zones + const getColor = (pct: number) => { + if (pct <= 50) return '#22C55E'; + if (pct <= 80) return '#F59E0B'; + return '#EF4444'; + }; + + const fillAngle = startAngle + (clampedPercent / 100) * sweepAngle; + + return ( +
+ + {/* Background track */} + + {/* Filled arc */} + {clampedPercent > 0 && ( + + )} + {/* Needle */} + + + {/* Center value */} + + {Math.round(clampedPercent)}% + + +
+
{label}
+ {sublabel &&
{sublabel}
} +
+
+ ); +} + +/* ──── Tooltip Bar for Daily Spending ──── */ +function DailyBar({ day, date, spent, cumulative, maxSpend, avgSpend }: { + day: number; date: string; spent: number; cumulative: number; maxSpend: number; avgSpend: number; +}) { + const [showTooltip, setShowTooltip] = useState(false); + const barRef = useRef(null); + const barHeight = maxSpend > 0 ? (spent / maxSpend) * 110 : 0; + const hasSpend = spent > 0; + const isSpike = spent > avgSpend * 1.5; + + return ( +
setShowTooltip(true)} + onMouseLeave={() => setShowTooltip(false)} + > + {/* Tooltip */} + {showTooltip && ( +
+
Day {day} · {formatDateDMY(date)}
+
Spent: ₹{(spent / 100).toLocaleString('en-IN')}
+
Cumulative: ₹{(cumulative / 100).toLocaleString('en-IN')}
+
+ )} +
+
+ ); +} + export default function ReportsPage() { const { refreshKey } = useApp(); @@ -41,6 +205,9 @@ export default function ReportsPage() { }, [selectedMonth]); const [compareMonth, setCompareMonth] = useState(prevMonthStr); + // Analytics view mode: 'category' or 'tag' + const [analyticsView, setAnalyticsView] = useState<'category' | 'tag'>('category'); + const [data, setData] = useState(null); const [forecast, setForecast] = useState(null); const [loading, setLoading] = useState(true); @@ -100,21 +267,31 @@ 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 splitAnalytics = data?.splitAnalytics || {}; - const splitCount = splitAnalytics.totalSplitTransactions ?? splitAnalytics.count ?? 0; - const splitVolume = splitAnalytics.totalSplitVolume ?? splitAnalytics.totalAmount ?? 0; - const splitCategories = splitAnalytics.topCategories ?? splitAnalytics.topSplitCategories ?? []; + const budgetHealth = data?.budgetHealth; const comparison = data?.comparison; - const metrics = data?.metrics || { totalIncome: 0, totalExpense: 0, netSavings: 0, savingsRate: 0 }; + const metrics = data?.metrics || {}; - // Calculate max daily spend for chart scaling - const maxDailySpend = Math.max(...dailySpending.map((d: any) => d.dailySpent ?? d.amount ?? 0), 100); - const totalMonthSpend = metrics.totalExpense; + // Fix: use correct field names from getDashboardMetrics + const totalMonthSpend = metrics.monthlyExpenses || 0; + const totalMonthIncome = metrics.monthlyIncome || 0; const daysInMonth = dailySpending.length || 30; - const avgDailySpend = daysInMonth > 0 ? Math.round(totalMonthSpend / daysInMonth) : 0; + const today = new Date(); + const [selYear, selMon] = selectedMonth.split('-').map(Number); + 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); + + // Savings rate for speedometer + const savingsRate = totalMonthIncome > 0 ? Math.round(((totalMonthIncome - totalMonthSpend) / totalMonthIncome) * 100) : 0; + + // Active analytics data based on view mode + const activeAnalytics = analyticsView === 'category' ? spendingByCategory : spendingByTag; + const analyticsLabel = analyticsView === 'category' ? 'Category' : 'Tag'; return (
@@ -123,7 +300,7 @@ export default function ReportsPage() {

Analytics & Reports

- Deep dive into monthly cash flows, month-over-month comparisons, daily burn rate, and 90-day forecasts. + Deep dive into monthly cash flows, spending patterns, budget health, and forecasts.

@@ -358,6 +535,54 @@ export default function ReportsPage() {
)} + {/* Speedometer Gauges Row */} +
+ {/* Savings Rate Gauge */} +
+
+ SAVINGS RATE +
+ +
+ + {/* Budget Utilization Gauge */} +
+
+ BUDGET UTILIZATION +
+ {budgetHealth ? ( + + ) : ( +
+ No budgets set for this month. +
+ )} +
+ + {/* Expense-to-Income Ratio Gauge */} +
+
+ EXPENSE RATIO +
+ 0 ? `${Math.round((totalMonthSpend / totalMonthIncome) * 100)}% of income` : 'No income recorded'} + /> +
+
+ {/* Daily Spending Burn Trend for Selected Month */}
@@ -367,66 +592,65 @@ export default function ReportsPage() { Daily Spending Burn Rate ({formatMonthDisplay(selectedMonth)})
- Daily expense velocity and spending spikes across all {daysInMonth} days. + Daily expense velocity and spending spikes. Hover over any bar for details.
- Average: ₹{avgDailySpend.toLocaleString('en-IN')}/day + Average: ₹{(avgDailySpend / 100).toLocaleString('en-IN', { maximumFractionDigits: 0 })}/day
- {/* Daily Bars Visualizer */} -
+ {/* Y-axis labels */} +
- {dailySpending.map((d: any) => { - const spent = d.dailySpent ?? d.amount ?? 0; - const cum = d.cumulativeSpent ?? d.cumulative ?? 0; - const barHeight = maxDailySpend > 0 ? (spent / maxDailySpend) * 110 : 0; - const hasSpend = spent > 0; + flexDirection: 'column', + justifyContent: 'space-between', + height: '140px', + paddingBottom: '1px', + minWidth: '48px', + textAlign: 'right', + paddingRight: '6px', + }}> + + ₹{Math.round(maxDailySpend / 100).toLocaleString('en-IN')} + + + ₹{Math.round(maxDailySpend / 200).toLocaleString('en-IN')} + + ₹0 +
- return ( -
+ {dailySpending.map((d: any) => ( + -
avgDailySpend * 1.5 * 100 - ? 'linear-gradient(180deg, #F43F5E, #BE123C)' - : 'linear-gradient(180deg, #6366F1, #4F46E5)' - : 'var(--border-subtle)', - borderRadius: '2px 2px 0 0', - transition: 'height 300ms ease', - cursor: 'pointer', - }} - /> -
- ); - })} + day={d.day} + date={d.date} + spent={d.dailySpent ?? d.amount ?? 0} + cumulative={d.cumulativeSpent ?? d.cumulative ?? 0} + maxSpend={maxDailySpend} + avgSpend={avgDailySpend * 100} + /> + ))} +
{/* Day scale markings */} -
+
Day 1 Day 7 Day 14 @@ -435,126 +659,172 @@ export default function ReportsPage() {
- {/* Split Transactions Breakdown & Forecast Grid */} -
- {/* Split Transactions Analysis */} + {/* Budget Health Summary */} + {budgetHealth && (
-
+

- Split Transactions Analytics + + Budget Health ({formatMonthDisplay(selectedMonth)})

- Multi-category receipts and itemized purchases + {budgetHealth.totalCategories} budget categories tracked · {budgetHealth.overallPercent}% overall utilization
- - {splitCount} splits - -
- -
-
-
SPLIT VOLUME
-
- ₹{Math.round(splitVolume / 100).toLocaleString('en-IN')} -
-
-
-
AVG PER SPLIT
-
- ₹{splitCount > 0 ? Math.round(splitVolume / splitCount / 100).toLocaleString('en-IN') : 0} -
+
+ {budgetHealth.overBudgetCount > 0 && ( + + {budgetHealth.overBudgetCount} Over Budget + + )} + {budgetHealth.nearLimitCount > 0 && ( + + {budgetHealth.nearLimitCount} Near Limit + + )} + {budgetHealth.healthyCount > 0 && ( + + {budgetHealth.healthyCount} Healthy + + )}
-
-
- MOST SPLIT CATEGORIES + {/* Over budget alerts */} + {budgetHealth.overBudgetItems.length > 0 && ( +
+
OVER BUDGET
+ {budgetHealth.overBudgetItems.map((b: any) => ( +
+ {b.category_name} +
+ + ₹{Math.round(b.spent / 100).toLocaleString('en-IN')} / ₹{Math.round(b.amount / 100).toLocaleString('en-IN')} + + {b.percentUsed}% +
+
+ ))}
- {splitCategories.length > 0 ? ( -
- {splitCategories.map((sc: any) => ( -
- {sc.name} - ₹{Math.round(sc.total / 100).toLocaleString('en-IN')} ({sc.count}x) + )} + + {/* Near limit warnings */} + {budgetHealth.nearLimitItems.length > 0 && ( +
+
APPROACHING LIMIT (≥80%)
+ {budgetHealth.nearLimitItems.map((b: any) => ( +
+ {b.category_name} +
+ + ₹{Math.round(b.spent / 100).toLocaleString('en-IN')} / ₹{Math.round(b.amount / 100).toLocaleString('en-IN')} + + {b.percentUsed}%
- ))} -
- ) : ( -
- No split transactions recorded for this month. -
- )} -
+
+ ))} +
+ )}
+ )} - {/* 30/60/90 Day Cash-Flow Forecast */} + {/* 2-Column Analytics: Category/Tag & Top Merchants */} +
+ {/* Spending by Category or Tag */}
-
-
-

30 / 60 / 90-Day Cash Flow Forecast

-
- Deterministic projections based on recurring schedules & obligations. -
-
- Estimate -
- -
- {periods.map((p: any) => ( -
+

+ Expenses by {analyticsLabel} ({formatMonthDisplay(selectedMonth)}) +

+ {/* Toggle: Category / Tag */} +
+
- ))} + Category + + +
-
-
- - {/* 2-Column Analytics: Categories & Top Merchants */} -
- {/* Spending by Category */} -
-

- Expenses by Category ({formatMonthDisplay(selectedMonth)}) -

- {spendingByCategory.length === 0 ? ( -
No category data available for this month.
+ {activeAnalytics.length === 0 ? ( +
+ No {analyticsLabel.toLowerCase()} data available for this month. +
) : (
- {spendingByCategory.map((c: any) => ( + {activeAnalytics.map((c: any) => (
- {c.name} + + {analyticsView === 'tag' && } + {c.name} + {analyticsView === 'tag' && c.count && ( + ({c.count} txns) + )} + ₹{Math.round(c.total / 100).toLocaleString('en-IN')} ({c.percentage}%) @@ -607,6 +877,48 @@ export default function ReportsPage() { )}
+ + {/* 30/60/90 Day Cash-Flow Forecast — moved to end */} + {periods.length > 0 && ( +
+
+
+

30 / 60 / 90-Day Cash Flow Forecast

+
+ Deterministic projections based on recurring schedules & obligations. +
+
+ Estimate +
+ +
+ {periods.map((p: any) => ( +
+
+
+ {p.days}-DAY PROJECTION +
+ +
+
+ In: +₹{Math.round(p.expectedIncome / 100).toLocaleString('en-IN')} + Out: -₹{Math.round(p.expectedExpenses / 100).toLocaleString('en-IN')} +
+
+ ))} +
+
+ )}
); } diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index 5c56c0e..be62e7e 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -2044,6 +2044,54 @@ export class FinanceService { })); } + getSpendingByTag(userId: string, monthStr?: string) { + const month = monthStr || new Date().toISOString().substring(0, 7); + const rows = this.db.prepare(` + SELECT tg.id, tg.name, tg.color, SUM(t.amount) as total, COUNT(t.id) as count + FROM transactions t + JOIN transaction_tags tt ON tt.transaction_id = t.id + JOIN tags tg ON tg.id = tt.tag_id + WHERE t.user_id = ? + AND t.type = 'expense' + AND t.is_deleted = 0 + AND t.date LIKE ? + GROUP BY tg.id + ORDER BY total DESC + `).all(userId, `${month}%`) as any[]; + + const totalExpense = rows.reduce((sum: number, r: any) => sum + r.total, 0); + + return rows.map((r: any) => ({ + ...r, + percentage: totalExpense > 0 ? Math.round((r.total / totalExpense) * 100) : 0, + })); + } + + getBudgetHealthSummary(userId: string, monthStr?: string) { + const budgets = this.getBudgets(userId, monthStr); + if (budgets.length === 0) return null; + + const totalBudgeted = budgets.reduce((sum: number, b: any) => sum + b.amount, 0); + const totalSpent = budgets.reduce((sum: number, b: any) => sum + b.spent, 0); + const overBudgetItems = budgets.filter((b: any) => b.isOverspent); + const nearLimitItems = budgets.filter((b: any) => !b.isOverspent && b.percentUsed >= 80); + const healthyItems = budgets.filter((b: any) => !b.isOverspent && b.percentUsed < 80); + const overallPercent = totalBudgeted > 0 ? Math.round((totalSpent / totalBudgeted) * 100) : 0; + + return { + totalBudgeted, + totalSpent, + totalRemaining: totalBudgeted - totalSpent, + overallPercent, + totalCategories: budgets.length, + overBudgetCount: overBudgetItems.length, + nearLimitCount: nearLimitItems.length, + healthyCount: healthyItems.length, + overBudgetItems: overBudgetItems.slice(0, 5), + nearLimitItems: nearLimitItems.slice(0, 5), + }; + } + getSpendingByMerchant(userId: string, limit = 10) { return this.db.prepare(` SELECT merchant_name, COUNT(*) as count, SUM(amount) as total From d591db1dbf0d2bfbf255eda540ea7202d4a6f621 Mon Sep 17 00:00:00 2001 From: Aditya Raj Date: Sun, 27 Sep 2026 13:53:31 +0530 Subject: [PATCH 2/6] Refactor tooltip and update savings label logic Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/app/reports/page.tsx | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index cfa59e6..5d7d304 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -55,9 +55,9 @@ function SpeedometerGauge({ value, max, label, sublabel }: { value: number; max: // Color zones const getColor = (pct: number) => { - if (pct <= 50) return '#22C55E'; - if (pct <= 80) return '#F59E0B'; - return '#EF4444'; + if (pct <= 50) return 'var(--color-income)'; + if (pct <= 80) return 'var(--color-warning)'; + return 'var(--color-expense)'; }; const fillAngle = startAngle + (clampedPercent / 100) * sweepAngle; @@ -139,8 +139,22 @@ function DailyBar({ day, date, spent, cumulative, maxSpend, avgSpend }: { justifyContent: 'flex-end', position: 'relative', }} +
setShowTooltip(true)} onMouseLeave={() => setShowTooltip(false)} + onFocus={() => setShowTooltip(true)} + onBlur={() => setShowTooltip(false)} > {/* Tooltip */} {showTooltip && ( @@ -545,7 +559,7 @@ export default function ReportsPage() { = totalMonthSpend ? `₹${Math.round((totalMonthIncome - totalMonthSpend) / 100).toLocaleString('en-IN')} saved` : `₹${Math.round((totalMonthSpend - totalMonthIncome) / 100).toLocaleString('en-IN')} deficit`} sublabel={`of ₹${Math.round(totalMonthIncome / 100).toLocaleString('en-IN')} income`} />
From 75115f121418ea6a795d956197cbf0b2b67ec3d9 Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Sun, 27 Sep 2026 13:57:12 +0530 Subject: [PATCH 3/6] fix: remove duplicate JSX root element in DailyBar component --- src/app/reports/page.tsx | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index 5d7d304..c07f7b5 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -128,17 +128,6 @@ function DailyBar({ day, date, spent, cumulative, maxSpend, avgSpend }: { const isSpike = spent > avgSpend * 1.5; return ( -
Date: Sun, 27 Sep 2026 14:48:43 +0530 Subject: [PATCH 4/6] feat(reports): add interactive SVG Donut chart with Split/Chart/List view modes for Category/Tag expenses --- src/app/reports/page.tsx | 523 ++++++++++++++++++++++++++++++++++----- 1 file changed, 460 insertions(+), 63 deletions(-) diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index c07f7b5..c5a8cf4 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -25,6 +25,9 @@ import { ShieldAlert, AlertTriangle, CheckCircle2, + PieChart, + List, + Columns2, } from 'lucide-react'; /* ──── SVG Speedometer Gauge ──── */ @@ -189,6 +192,199 @@ function DailyBar({ day, date, spent, cumulative, maxSpend, avgSpend }: { ); } +/* ──── Interactive SVG Donut / Pie Chart ──── */ +const DONUT_PALETTE = [ + '#6366F1', '#EC4899', '#10B981', '#F59E0B', '#3B82F6', + '#8B5CF6', '#14B8A6', '#F43F5E', '#06B6D4', '#EAB308', + '#84CC16', '#A855F7', +]; + +interface DonutSliceData { + id: string; + name: string; + total: number; + percentage: number; + color: string; + count?: number; +} + +function ExpenseDonutChart({ + items, + totalSpend, + analyticsLabel, + hoveredId, + onHover, +}: { + items: Array<{ id: string; name: string; total: number; percentage: number; color?: string; count?: number }>; + totalSpend: number; + analyticsLabel: string; + hoveredId: string | null; + onHover: (id: string | null) => void; +}) { + const cx = 110; + const cy = 110; + const rIn = 60; + const rOutDefault = 88; + const rOutHover = 96; + + // Curated color mapping + const slices: DonutSliceData[] = useMemo(() => { + return items.map((it, idx) => ({ + ...it, + color: it.color || DONUT_PALETTE[idx % DONUT_PALETTE.length], + })); + }, [items]); + + const totalSum = useMemo(() => { + return slices.reduce((acc, s) => acc + (s.total || 0), 0); + }, [slices]); + + // Angles for each slice + const sliceAngles = useMemo(() => { + if (totalSum <= 0 || slices.length === 0) return []; + let currentAngle = 0; + return slices.map(s => { + const angleSweep = (s.total / totalSum) * 360; + const startAngle = currentAngle; + const endAngle = currentAngle + angleSweep; + currentAngle = endAngle; + return { ...s, startAngle, endAngle, angleSweep }; + }); + }, [slices, totalSum]); + + const activeSlice = useMemo(() => { + if (!hoveredId) return null; + return slices.find(s => s.id === hoveredId) || null; + }, [hoveredId, slices]); + + const toRad = (deg: number) => ((deg - 90) * Math.PI) / 180; + + const getPath = (startAngle: number, endAngle: number, isHovered: boolean) => { + const rOut = isHovered ? rOutHover : rOutDefault; + const sweep = endAngle - startAngle; + + if (sweep >= 359.9) { + return ` + M ${cx} ${cy - rOut} + A ${rOut} ${rOut} 0 1 1 ${cx - 0.01} ${cy - rOut} + L ${cx - 0.01} ${cy - rIn} + A ${rIn} ${rIn} 0 1 0 ${cx} ${cy - rIn} + Z + `; + } + + const gap = slices.length > 1 ? Math.min(1.5, sweep * 0.1) : 0; + const actualStart = startAngle + gap / 2; + const actualEnd = endAngle - gap / 2; + + const x1 = cx + rOut * Math.cos(toRad(actualStart)); + const y1 = cy + rOut * Math.sin(toRad(actualStart)); + const x2 = cx + rOut * Math.cos(toRad(actualEnd)); + const y2 = cy + rOut * Math.sin(toRad(actualEnd)); + + const x3 = cx + rIn * Math.cos(toRad(actualEnd)); + const y3 = cy + rIn * Math.sin(toRad(actualEnd)); + const x4 = cx + rIn * Math.cos(toRad(actualStart)); + const y4 = cy + rIn * Math.sin(toRad(actualStart)); + + const largeArc = (actualEnd - actualStart) > 180 ? 1 : 0; + + return ` + M ${x1} ${y1} + A ${rOut} ${rOut} 0 ${largeArc} 1 ${x2} ${y2} + L ${x3} ${y3} + A ${rIn} ${rIn} 0 ${largeArc} 0 ${x4} ${y4} + Z + `; + }; + + if (slices.length === 0 || totalSum === 0) { + return ( +
+ + + No expenses + +
+ ); + } + + return ( +
+ + {/* Background center circle */} + + + {/* Slices */} + {sliceAngles.map(s => { + const isHovered = hoveredId === s.id; + const isDimmed = hoveredId !== null && !isHovered; + return ( + onHover(s.id)} + onMouseLeave={() => onHover(null)} + tabIndex={0} + onFocus={() => onHover(s.id)} + onBlur={() => onHover(null)} + /> + ); + })} + + {/* Center cutout label */} + + {activeSlice ? (activeSlice.name.length > 14 ? activeSlice.name.substring(0, 12) + '…' : activeSlice.name) : 'TOTAL SPENT'} + + + + ₹{Math.round((activeSlice ? activeSlice.total : (totalSpend || totalSum)) / 100).toLocaleString('en-IN')} + + + + {activeSlice ? `${activeSlice.percentage}% of total` : `${slices.length} ${analyticsLabel.toLowerCase()}s`} + + +
+ ); +} + export default function ReportsPage() { const { refreshKey } = useApp(); @@ -211,6 +407,10 @@ export default function ReportsPage() { // Analytics view mode: 'category' or 'tag' const [analyticsView, setAnalyticsView] = useState<'category' | 'tag'>('category'); + // Chart presentation mode: 'split' | 'chart' | 'list' + const [chartLayout, setChartLayout] = useState<'split' | 'chart' | 'list'>('split'); + const [hoveredAnalyticsId, setHoveredAnalyticsId] = useState(null); + const [data, setData] = useState(null); const [forecast, setForecast] = useState(null); const [loading, setLoading] = useState(true); @@ -758,91 +958,288 @@ label={totalMonthIncome >= totalMonthSpend ? `₹${Math.round((totalMonthIncome {/* 2-Column Analytics: Category/Tag & Top Merchants */}
- {/* Spending by Category or Tag */} + {/* Spending by Category or Tag with Donut Chart / Split View */}
-

+

+ Expenses by {analyticsLabel} ({formatMonthDisplay(selectedMonth)})

- {/* Toggle: Category / Tag */} -
- - + + +
+ + {/* Dimension Switcher: Category vs Tag */} +
- Tag - + + +
+ {activeAnalytics.length === 0 ? (
No {analyticsLabel.toLowerCase()} data available for this month.
) : ( -
- {activeAnalytics.map((c: any) => ( -
-
- - {analyticsView === 'tag' && } - {c.name} - {analyticsView === 'tag' && c.count && ( - ({c.count} txns) - )} - - - ₹{Math.round(c.total / 100).toLocaleString('en-IN')} ({c.percentage}%) - +
+ {/* Split Mode: Donut Chart on left + Progress List on right */} + {chartLayout === 'split' && ( +
+ + +
+ {activeAnalytics.map((c: any) => { + const isHovered = hoveredAnalyticsId === c.id; + return ( +
setHoveredAnalyticsId(c.id)} + onMouseLeave={() => setHoveredAnalyticsId(null)} + style={{ + padding: '0.35rem 0.5rem', + borderRadius: 'var(--radius-sm)', + backgroundColor: isHovered ? 'var(--bg-subtle)' : 'transparent', + transition: 'background-color 150ms ease', + cursor: 'pointer', + }} + > +
+ + + {c.name} + {analyticsView === 'tag' && c.count && ( + ({c.count} txns) + )} + + + ₹{Math.round(c.total / 100).toLocaleString('en-IN')} ({c.percentage}%) + +
+
+
+
+
+ ); + })}
-
-
+
+ )} + + {/* Chart-Only Mode: Large Donut Chart + Interactive Badges */} + {chartLayout === 'chart' && ( +
+ + + {/* Interactive Badges / Pill Legend */} +
+ {activeAnalytics.map((c: any) => { + const isHovered = hoveredAnalyticsId === c.id; + return ( + + ); + })}
- ))} + )} + + {/* List-Only Mode: Full Width Progress Bars */} + {chartLayout === 'list' && ( +
+ {activeAnalytics.map((c: any) => ( +
+
+ + + {c.name} + {analyticsView === 'tag' && c.count && ( + ({c.count} txns) + )} + + + ₹{Math.round(c.total / 100).toLocaleString('en-IN')} ({c.percentage}%) + +
+
+
+
+
+ ))} +
+ )}
)}
From 001af92bb366560af3f20d20e3c6bf4b12f54af4 Mon Sep 17 00:00:00 2001 From: AdityaRaj212 Date: Sun, 27 Sep 2026 15:27:55 +0530 Subject: [PATCH 5/6] feat(reports): account for uncategorized/untagged expenses and streamline speedometer gauges --- src/app/reports/page.tsx | 23 +++---------- src/lib/finance-service.ts | 68 +++++++++++++++++++++++++++++++++----- 2 files changed, 65 insertions(+), 26 deletions(-) diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index c5a8cf4..b88b5b9 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -738,8 +738,8 @@ export default function ReportsPage() {
)} - {/* Speedometer Gauges Row */} -
+ {/* Speedometer Gauges Row: Savings Rate & Budget Health */} +
{/* Savings Rate Gauge */}
@@ -748,8 +748,8 @@ export default function ReportsPage() { = totalMonthSpend ? `₹${Math.round((totalMonthIncome - totalMonthSpend) / 100).toLocaleString('en-IN')} saved` : `₹${Math.round((totalMonthSpend - totalMonthIncome) / 100).toLocaleString('en-IN')} deficit`} - sublabel={`of ₹${Math.round(totalMonthIncome / 100).toLocaleString('en-IN')} income`} + label={totalMonthIncome >= totalMonthSpend ? `₹${Math.round((totalMonthIncome - totalMonthSpend) / 100).toLocaleString('en-IN')} saved` : `₹${Math.round((totalMonthSpend - totalMonthIncome) / 100).toLocaleString('en-IN')} deficit`} + sublabel={`of ₹${Math.round(totalMonthIncome / 100).toLocaleString('en-IN')} income (${Math.round(totalMonthIncome > 0 ? (totalMonthSpend / totalMonthIncome) * 100 : 0)}% spent)`} />
@@ -763,7 +763,7 @@ label={totalMonthIncome >= totalMonthSpend ? `₹${Math.round((totalMonthIncome value={budgetHealth.overallPercent} max={100} label={`₹${Math.round(budgetHealth.totalSpent / 100).toLocaleString('en-IN')} spent`} - sublabel={`of ₹${Math.round(budgetHealth.totalBudgeted / 100).toLocaleString('en-IN')} budgeted`} + sublabel={`of ₹${Math.round(budgetHealth.totalBudgeted / 100).toLocaleString('en-IN')} budgeted (${budgetHealth.healthyCount} healthy, ${budgetHealth.overBudgetCount} over)`} /> ) : (
@@ -771,19 +771,6 @@ label={totalMonthIncome >= totalMonthSpend ? `₹${Math.round((totalMonthIncome
)}
- - {/* Expense-to-Income Ratio Gauge */} -
-
- EXPENSE RATIO -
- 0 ? `${Math.round((totalMonthSpend / totalMonthIncome) * 100)}% of income` : 'No income recorded'} - /> -
{/* Daily Spending Burn Trend for Selected Month */} diff --git a/src/lib/finance-service.ts b/src/lib/finance-service.ts index be62e7e..04ec9da 100644 --- a/src/lib/finance-service.ts +++ b/src/lib/finance-service.ts @@ -2024,20 +2024,36 @@ export class FinanceService { getSpendingByCategory(userId: string, monthStr?: string) { const month = monthStr || new Date().toISOString().substring(0, 7); + + // Total expense for the month across all transactions (including uncategorized) + const totalRow = this.db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total + FROM transactions + WHERE user_id = ? + AND type = 'expense' + AND is_deleted = 0 + AND date LIKE ? + `).get(userId, `${month}%`) as any; + const totalExpense = totalRow?.total || 0; + const rows = this.db.prepare(` - SELECT c.id, c.name, c.icon, c.color, SUM(t.amount) as total + SELECT + COALESCE(c.id, 'uncategorized') as id, + COALESCE(c.name, 'Uncategorized') as name, + COALESCE(c.icon, 'help-circle') as icon, + COALESCE(c.color, '#94A3B8') as color, + SUM(t.amount) as total, + COUNT(t.id) as count FROM transactions t - JOIN categories c ON c.id = t.category_id + LEFT JOIN categories c ON c.id = t.category_id WHERE t.user_id = ? AND t.type = 'expense' AND t.is_deleted = 0 AND t.date LIKE ? - GROUP BY c.id + GROUP BY COALESCE(c.id, 'uncategorized') ORDER BY total DESC `).all(userId, `${month}%`) as any[]; - const totalExpense = rows.reduce((sum, r) => sum + r.total, 0); - return rows.map(r => ({ ...r, percentage: totalExpense > 0 ? Math.round((r.total / totalExpense) * 100) : 0, @@ -2046,7 +2062,20 @@ export class FinanceService { getSpendingByTag(userId: string, monthStr?: string) { const month = monthStr || new Date().toISOString().substring(0, 7); - const rows = this.db.prepare(` + + // Total expense for the month across all transactions + const totalRow = this.db.prepare(` + SELECT COALESCE(SUM(amount), 0) as total + FROM transactions + WHERE user_id = ? + AND type = 'expense' + AND is_deleted = 0 + AND date LIKE ? + `).get(userId, `${month}%`) as any; + const totalExpense = totalRow?.total || 0; + + // Tagged transactions + const taggedRows = this.db.prepare(` SELECT tg.id, tg.name, tg.color, SUM(t.amount) as total, COUNT(t.id) as count FROM transactions t JOIN transaction_tags tt ON tt.transaction_id = t.id @@ -2059,9 +2088,32 @@ export class FinanceService { ORDER BY total DESC `).all(userId, `${month}%`) as any[]; - const totalExpense = rows.reduce((sum: number, r: any) => sum + r.total, 0); + // Untagged transactions (expense transactions with no tags in transaction_tags) + const untaggedRow = this.db.prepare(` + SELECT COALESCE(SUM(t.amount), 0) as total, COUNT(t.id) as count + FROM transactions t + WHERE t.user_id = ? + AND t.type = 'expense' + AND t.is_deleted = 0 + AND t.date LIKE ? + AND NOT EXISTS ( + SELECT 1 FROM transaction_tags tt WHERE tt.transaction_id = t.id + ) + `).get(userId, `${month}%`) as any; + + const allRows = [...taggedRows]; + if (untaggedRow && untaggedRow.total > 0) { + allRows.push({ + id: 'untagged', + name: 'Untagged', + color: '#94A3B8', + total: untaggedRow.total, + count: untaggedRow.count, + }); + allRows.sort((a: any, b: any) => b.total - a.total); + } - return rows.map((r: any) => ({ + return allRows.map((r: any) => ({ ...r, percentage: totalExpense > 0 ? Math.round((r.total / totalExpense) * 100) : 0, })); From 555af4698f2594f480eac71fe00983453c7ee95d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 27 Sep 2026 10:03:18 +0000 Subject: [PATCH 6/6] fix: address targeted PR review comments Co-authored-by: AdityaRaj212 <127577061+AdityaRaj212@users.noreply.github.com> --- src/app/api/export/route.ts | 23 +++++++++++++++-------- src/app/reports/page.tsx | 8 ++++---- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts index 9ca78ff..12d4771 100644 --- a/src/app/api/export/route.ts +++ b/src/app/api/export/route.ts @@ -5,6 +5,13 @@ import { FinanceService } from '@/lib/finance-service'; export const runtime = 'nodejs'; +const toSafeCsvCell = (value: unknown) => { + const text = value == null ? '' : String(value); + const escaped = text.replace(/"/g, '""'); + const shouldEscapeFormula = /^[=+\-@]/.test(escaped.trimStart()); + return `"${shouldEscapeFormula ? `'${escaped}` : escaped}"`; +}; + export async function GET(req: Request) { try { const user = getCurrentUser(req); @@ -21,15 +28,15 @@ export async function GET(req: Request) { const amt = (t.amount / 100).toFixed(2); const tagStr = Array.isArray(t.tags) ? t.tags.join('; ') : ''; const line = [ - t.date, - t.type, + toSafeCsvCell(t.date), + toSafeCsvCell(t.type), amt, - t.currency, - `"${(t.account_name || '').replace(/"/g, '""')}"`, - `"${(t.category_name || '').replace(/"/g, '""')}"`, - `"${(t.merchant_name || '').replace(/"/g, '""')}"`, - `"${tagStr.replace(/"/g, '""')}"`, - `"${(t.notes || '').replace(/"/g, '""')}"`, + toSafeCsvCell(t.currency), + toSafeCsvCell(t.account_name), + toSafeCsvCell(t.category_name), + toSafeCsvCell(t.merchant_name), + toSafeCsvCell(tagStr), + toSafeCsvCell(t.notes), ].join(','); csv += line + '\n'; } diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index b88b5b9..4afcca2 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -67,7 +67,7 @@ function SpeedometerGauge({ value, max, label, sublabel }: { value: number; max: return (
- + {/* Background track */}
{budgetHealth.overBudgetCount > 0 && ( - + {budgetHealth.overBudgetCount} Over Budget )} {budgetHealth.nearLimitCount > 0 && ( - + {budgetHealth.nearLimitCount} Near Limit )} {budgetHealth.healthyCount > 0 && ( - + {budgetHealth.healthyCount} Healthy )}