diff --git a/src/app/api/export/route.ts b/src/app/api/export/route.ts
index a2a5046..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);
@@ -16,18 +23,20 @@ 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,
+ toSafeCsvCell(t.date),
+ toSafeCsvCell(t.type),
amt,
- t.currency,
- `"${(t.account_name || '').replace(/"/g, '""')}"`,
- `"${(t.category_name || '').replace(/"/g, '""')}"`,
- `"${(t.merchant_name || '').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/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..4afcca2 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,374 @@ import {
ArrowRight,
ArrowUpRight,
ArrowDownRight,
- Split,
Flame,
Scale,
+ Tag,
+ Gauge,
+ ShieldCheck,
+ ShieldAlert,
+ AlertTriangle,
+ CheckCircle2,
+ PieChart,
+ List,
+ Columns2,
} 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 'var(--color-income)';
+ if (pct <= 80) return 'var(--color-warning)';
+ return 'var(--color-expense)';
+ };
+
+ 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)}
+ onFocus={() => setShowTooltip(true)}
+ onBlur={() => setShowTooltip(false)}
+ >
+ {/* Tooltip */}
+ {showTooltip && (
+
+
Day {day} · {formatDateDMY(date)}
+
Spent: ₹{(spent / 100).toLocaleString('en-IN')}
+
Cumulative: ₹{(cumulative / 100).toLocaleString('en-IN')}
+
+ )}
+
+
+ );
+}
+
+/* ──── 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();
@@ -41,6 +404,13 @@ export default function ReportsPage() {
}, [selectedMonth]);
const [compareMonth, setCompareMonth] = useState(prevMonthStr);
+ // 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);
@@ -100,21 +470,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 +503,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 +738,41 @@ export default function ReportsPage() {
)}
+ {/* Speedometer Gauges Row: Savings Rate & Budget Health */}
+
+ {/* Savings Rate Gauge */}
+
+
+ SAVINGS RATE
+
+
= 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)`}
+ />
+
+
+ {/* Budget Utilization Gauge */}
+
+
+ BUDGET UTILIZATION
+
+ {budgetHealth ? (
+
+ ) : (
+
+ No budgets set for this month.
+
+ )}
+
+
+
{/* Daily Spending Burn Trend for Selected Month */}
@@ -367,66 +782,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,141 +849,384 @@ 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 with Donut Chart / Split View */}
-
-
-
30 / 60 / 90-Day Cash Flow Forecast
-
- Deterministic projections based on recurring schedules & obligations.
-
-
-
Estimate
-
+
+
+
+ Expenses by {analyticsLabel} ({formatMonthDisplay(selectedMonth)})
+
-
- {periods.map((p: any) => (
+
+ {/* Layout Switcher: Split | Chart | List */}
+
setChartLayout('split')}
+ style={{
+ padding: '0.3rem 0.55rem',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ border: 'none',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.25rem',
+ backgroundColor: chartLayout === 'split' ? 'var(--brand-primary)' : 'var(--bg-subtle)',
+ color: chartLayout === 'split' ? '#fff' : 'var(--text-secondary)',
+ transition: 'all 150ms ease',
+ }}
+ >
+ Split
+
+
setChartLayout('chart')}
+ style={{
+ padding: '0.3rem 0.55rem',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ border: 'none',
+ borderLeft: '1px solid var(--border-default)',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.25rem',
+ backgroundColor: chartLayout === 'chart' ? 'var(--brand-primary)' : 'var(--bg-subtle)',
+ color: chartLayout === 'chart' ? '#fff' : 'var(--text-secondary)',
+ transition: 'all 150ms ease',
+ }}
+ >
+ Chart
+
+
setChartLayout('list')}
+ style={{
+ padding: '0.3rem 0.55rem',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ border: 'none',
+ borderLeft: '1px solid var(--border-default)',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.25rem',
+ backgroundColor: chartLayout === 'list' ? 'var(--brand-primary)' : 'var(--bg-subtle)',
+ color: chartLayout === 'list' ? '#fff' : 'var(--text-secondary)',
+ transition: 'all 150ms ease',
+ }}
+ >
+
List
+
+
+
+ {/* Dimension Switcher: Category vs Tag */}
+
-
-
- {p.days}-DAY PROJECTION
-
-
-
-
- In: +₹{Math.round(p.expectedIncome / 100).toLocaleString('en-IN')}
- Out: -₹{Math.round(p.expectedExpenses / 100).toLocaleString('en-IN')}
-
+
setAnalyticsView('category')}
+ style={{
+ padding: '0.3rem 0.65rem',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ border: 'none',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.3rem',
+ backgroundColor: analyticsView === 'category' ? 'var(--brand-primary)' : 'var(--bg-subtle)',
+ color: analyticsView === 'category' ? '#fff' : 'var(--text-secondary)',
+ transition: 'all 150ms ease',
+ }}
+ >
+ Category
+
+
setAnalyticsView('tag')}
+ style={{
+ padding: '0.3rem 0.65rem',
+ fontSize: '0.75rem',
+ fontWeight: 600,
+ border: 'none',
+ borderLeft: '1px solid var(--border-default)',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.3rem',
+ backgroundColor: analyticsView === 'tag' ? 'var(--brand-primary)' : 'var(--bg-subtle)',
+ color: analyticsView === 'tag' ? '#fff' : 'var(--text-secondary)',
+ transition: 'all 150ms ease',
+ }}
+ >
+ Tag
+
- ))}
+
-
-
- {/* 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) => (
-
-
-
{c.name}
-
- ₹{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 (
+ setHoveredAnalyticsId(c.id)}
+ onMouseLeave={() => setHoveredAnalyticsId(null)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: '0.35rem',
+ padding: '0.3rem 0.6rem',
+ borderRadius: '9999px',
+ fontSize: '0.75rem',
+ fontWeight: 500,
+ backgroundColor: isHovered ? 'var(--bg-subtle)' : 'var(--bg-surface)',
+ border: `1px solid ${isHovered ? c.color || 'var(--brand-primary)' : 'var(--border-default)'}`,
+ color: 'var(--text-primary)',
+ cursor: 'pointer',
+ transition: 'all 150ms ease',
+ }}
+ >
+
+ {c.name}
+ {c.percentage}%
+
+ );
+ })}
- ))}
+ )}
+
+ {/* 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}%)
+
+
+
+
+ ))}
+
+ )}
)}
@@ -607,6 +1264,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..04ec9da 100644
--- a/src/lib/finance-service.ts
+++ b/src/lib/finance-service.ts
@@ -2024,26 +2024,126 @@ 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,
}));
}
+ getSpendingByTag(userId: string, monthStr?: string) {
+ const month = monthStr || new Date().toISOString().substring(0, 7);
+
+ // 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
+ 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[];
+
+ // 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 allRows.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