Compare commits

...

5 Commits

3 changed files with 307 additions and 121 deletions

View File

@@ -36,8 +36,9 @@ export default function Dashboard() {
income: null income: null
}); });
const [mode, setMode] = const [mode, setMode] = React.useState<"expense" | "income">("expense");
React.useState<"expense" | "income">("expense"); const [period, setPeriod] = React.useState<"rolling" | "calendar">("rolling");
const [comparison, setComparison] = React.useState(false);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -130,8 +131,12 @@ export default function Dashboard() {
<HistoryChart <HistoryChart
header={`${mode === "expense" ? "Expense" : "Income"} Breakdown`} header={`${mode === "expense" ? "Expense" : "Income"} Breakdown`}
summary="Interactive chronological tracking" summary="Interactive chronological tracking"
tabs={["Week", "Month", "Year"]} tabs={["Daily", "Weekly", "Monthly"]}
data={currentData?.chartData || {}} data={currentData.chartData}
period={period}
onPeriodChange={setPeriod}
comparison={comparison}
setComparison={setComparison}
/> />
</Grid> </Grid>
</Grid> </Grid>

View File

@@ -4,15 +4,31 @@ import { Box, Typography, ToggleButtonGroup, ToggleButton, Paper } from "@mui/ma
export interface ChartDataPoint { export interface ChartDataPoint {
id: string; id: string;
amount: number; amount: number;
compareAmount?: number;
count?: number; count?: number;
highlighted?: boolean; highlighted?: boolean;
} }
export interface ChartSeries {
rolling: ChartDataPoint[];
calendar: ChartDataPoint[];
}
export interface ChartData {
daily: ChartDataPoint[];
weekly: ChartSeries;
monthly: ChartSeries;
}
export interface HistoryChartProps { export interface HistoryChartProps {
header: string; header: string;
summary?: string; summary?: string;
tabs: string[]; tabs: string[];
data: Record<string, ChartDataPoint[]>; data: ChartData;
period: "rolling" | "calendar";
onPeriodChange: (mode: "rolling" | "calendar") => void;
comparison: boolean;
setComparison: (mode: boolean) => void;
} }
export default function HistoryChart({ export default function HistoryChart({
@@ -20,6 +36,10 @@ export default function HistoryChart({
summary, summary,
tabs, tabs,
data, data,
period,
onPeriodChange,
comparison,
setComparison,
}: HistoryChartProps) { }: HistoryChartProps) {
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || ""); const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
@@ -29,48 +49,69 @@ export default function HistoryChart({
} }
}; };
const activeDataKey = activeTab.toLowerCase(); const activeDataKey = activeTab.toLowerCase() as keyof ChartData;
const rawData = data[activeDataKey] || data[activeTab] || [];
const currentData = [...rawData].reverse(); let rawData: ChartDataPoint[] = [];
if (activeDataKey === "daily") {
rawData = data.daily || [];
} else {
const section = data[activeDataKey];
rawData = section?.[period] || [];
}
const currentData = rawData;
const maxAmount = const maxAmount =
currentData.length > 0 currentData.length > 0
? Math.max(...currentData.map((d) => d.amount), 1) ? Math.max(
...currentData.flatMap((d) =>
comparison ? [d.amount, d.compareAmount || 0] : [d.amount]
),
1
)
: 1; : 1;
// ✅ Formatter (₹ + adaptive units)
const formatAmount = (amount: number) => { const formatAmount = (amount: number) => {
const tab = activeTab.toLowerCase(); const tab = activeTab.toLowerCase();
if (amount === 0) return ""; if (amount === 0) return "";
if (tab === "year") { if (tab === "monthly") {
if (amount >= 100000) { if (amount >= 100000) return `${(amount / 100000).toFixed(2)} L`;
return `${(amount / 100000).toFixed(2)} L`;
}
return `${amount.toLocaleString("en-IN")}`; return `${amount.toLocaleString("en-IN")}`;
} }
if (tab === "month") { if (tab === "weekly") {
if (amount >= 1000) { if (amount >= 1000) return `${(amount / 1000).toFixed(1)} K`;
return `${(amount / 1000).toFixed(1)} K`;
}
return `${amount.toLocaleString("en-IN")}`; return `${amount.toLocaleString("en-IN")}`;
} }
return `${amount.toLocaleString("en-IN")}`; return `${amount.toLocaleString("en-IN")}`;
}; };
return ( return (
<Paper sx={{ p: { xs: 2, sm: 4 }, borderRadius: 4, width: "100%", boxShadow: 'none', border: '1px solid', borderColor: 'divider' }}> <Paper
sx={{
p: { xs: 2, sm: 4 },
borderRadius: 4,
width: "100%",
boxShadow: "none",
border: "1px solid",
borderColor: "divider"
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom> <Typography variant="h6" fontWeight={700} gutterBottom>
{header} {header}
</Typography> </Typography>
{summary && ( {summary && (
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}> <Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
{summary} {summary}
</Typography> </Typography>
)} )}
{/* Tabs */}
<ToggleButtonGroup <ToggleButtonGroup
value={activeTab} value={activeTab}
exclusive exclusive
@@ -78,7 +119,10 @@ export default function HistoryChart({
fullWidth fullWidth
sx={{ sx={{
mb: 4, mb: 4,
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.02)', bgcolor: (theme) =>
theme.palette.mode === "dark"
? "rgba(255,255,255,0.05)"
: "rgba(0,0,0,0.02)",
borderRadius: 8, borderRadius: 8,
p: 0.5, p: 0.5,
"& .MuiToggleButton-root": { "& .MuiToggleButton-root": {
@@ -88,11 +132,16 @@ export default function HistoryChart({
fontWeight: 600, fontWeight: 600,
color: "text.secondary", color: "text.secondary",
"&.Mui-selected": { "&.Mui-selected": {
bgcolor: (theme) => theme.palette.mode === 'dark' ? 'primary.dark' : 'primary.light', bgcolor: (theme) =>
color: (theme) => theme.palette.mode === 'dark' ? 'primary.contrastText' : 'primary.main', theme.palette.mode === "dark"
boxShadow: '0 2px 8px rgba(0,0,0,0.05)', ? "primary.dark"
}, : "primary.light",
}, color: (theme) =>
theme.palette.mode === "dark"
? "primary.contrastText"
: "primary.main"
}
}
}} }}
> >
{tabs.map((tab) => ( {tabs.map((tab) => (
@@ -102,58 +151,98 @@ export default function HistoryChart({
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
{/* Chart Area */} {/* Period Toggle */}
<ToggleButtonGroup
value={period}
exclusive
onChange={(_, v) => v && onPeriodChange(v)}
size="small"
sx={{ mb: 2 }}
>
<ToggleButton value="rolling">Rolling</ToggleButton>
<ToggleButton value="calendar" disabled={activeDataKey === "daily"}>
Calendar
</ToggleButton>
</ToggleButtonGroup>
<ToggleButtonGroup
value={comparison ? "on" : "off"}
exclusive
onChange={(_, v) => setComparison(v === "on")}
size="small"
sx={{ mb: 2 }}
>
<ToggleButton value="off">Single</ToggleButton>
<ToggleButton value="on">Compare</ToggleButton>
</ToggleButtonGroup>
{/* Chart */}
{currentData.length > 0 ? ( {currentData.length > 0 ? (
<Box sx={{ display: "flex", alignItems: "flex-end", height: 200, mt: 4, position: 'relative' }}> <Box sx={{ display: "flex", alignItems: "flex-end", height: 220, mt: 4 }}>
{currentData.map((point) => { {currentData.map((point) => {
const heightPerc = (point.amount / maxAmount) * 100; const currentHeight = (point.amount / maxAmount) * 100;
const compareHeight = ((point.compareAmount || 0) / maxAmount) * 100;
return ( return (
<Box <Box
key={ key={point.id}
activeTab.toLowerCase() === "month"
? point.id.replace(" - ", "\n")
: point.id
}
sx={{ sx={{
flex: 1, flex: 1,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", justifyContent: "flex-end",
height: "100%", height: "100%"
}} }}
> >
{/* Values */}
<Typography variant="caption" sx={{ mb: 1, fontSize: "0.65rem" }}>
{formatAmount(point.amount)}
</Typography>
{/* Bars */}
<Box
sx={{
display: "flex",
alignItems: "flex-end",
gap: comparison ? 0.5 : 0,
height: "100%"
}}
>
{/* Compare */}
{comparison && (
<Box
sx={{
width: 6,
height: `${compareHeight}%`,
bgcolor: "grey.400",
borderRadius: 2
}}
/>
)}
{/* Current */}
<Box
sx={{
width: 10,
height: `${currentHeight}%`,
bgcolor: point.highlighted ? "error.main" : "primary.main",
borderRadius: 2
}}
/>
</Box>
{/* Label */}
<Typography <Typography
variant="caption" variant="caption"
color="text.secondary" color="text.secondary"
sx={{ sx={{
mt: 1, mt: 1,
fontWeight: 500, fontSize: "0.7rem",
fontSize: '0.7rem', textAlign: "center",
textAlign: 'center', whiteSpace: "pre-line"
whiteSpace: 'pre-line'
}} }}
> >
{point.amount > 0 ? formatAmount(point.amount) : ""}
</Typography>
<Box
sx={{
width: "40%",
minWidth: 12,
maxWidth: 32,
height: `${heightPerc}%`,
minHeight: "4px",
bgcolor: point.highlighted ? "error.main" : "grey.300",
borderRadius: 4,
transition: "height 0.5s cubic-bezier(0.4, 0, 0.2, 1)",
...(point.highlighted && {
boxShadow: (theme) => `0 4px 12px ${theme.palette.error.main}40`,
}),
}}
/>
<Typography variant="caption" color="text.secondary" sx={{ mt: 1, fontWeight: 500, fontSize: '0.7rem' }}>
{point.id} {point.id}
</Typography> </Typography>
</Box> </Box>

View File

@@ -32,6 +32,9 @@ const getStartOfWeek = (d: Date) => {
return startOfDay(date); return startOfDay(date);
}; };
const shiftDate = (d: Date, days: number) =>
new Date(d.getTime() + days * 86400000);
// ---------------- LATEST ---------------- // ---------------- LATEST ----------------
export async function fetchLatestTransactions( export async function fetchLatestTransactions(
type: "expense" | "income" type: "expense" | "income"
@@ -71,13 +74,23 @@ export async function fetchLatestTransactions(
} }
// ---------------- TYPES ---------------- // ---------------- TYPES ----------------
export interface ChartSeries {
rolling: ChartDataPoint[];
calendar: ChartDataPoint[];
}
export interface ChartData {
daily: ChartDataPoint[];
weekly: ChartSeries;
monthly: ChartSeries;
}
export interface AggregatedDashboardData { export interface AggregatedDashboardData {
chartData: Record<string, ChartDataPoint[]>; chartData: ChartData;
totalAmount: number; totalAmount: number;
topPayees: Array<{ payeeName: string; amount: number }>; topPayees: Array<{ payeeName: string; amount: number }>;
} }
// ---------------- AGGREGATION ----------------
// ---------------- AGGREGATION ---------------- // ---------------- AGGREGATION ----------------
export async function fetchAggregatedData( export async function fetchAggregatedData(
type: "expense" | "income" type: "expense" | "income"
@@ -95,65 +108,122 @@ export async function fetchAggregatedData(
const normalize = (amt: number) => Math.abs(amt); const normalize = (amt: number) => Math.abs(amt);
// ---------------- WEEK ---------------- // ---------------- DAILY ----------------
const weekBuckets: Record<string, number> = { const dailyBuckets: Record<string, any> = {
Mon: 0, Tue: 0, Wed: 0, Thu: 0, Mon: { amount: 0, compare: 0 },
Fri: 0, Sat: 0, Sun: 0 Tue: { amount: 0, compare: 0 },
Wed: { amount: 0, compare: 0 },
Thu: { amount: 0, compare: 0 },
Fri: { amount: 0, compare: 0 },
Sat: { amount: 0, compare: 0 },
Sun: { amount: 0, compare: 0 }
}; };
const weekStart = getStartOfWeek(now); const weekStart = getStartOfWeek(now);
const weekEnd = endOfDay(new Date(weekStart.getTime() + 6 * 86400000)); const weekEnd = endOfDay(new Date(weekStart.getTime() + 6 * 86400000));
const prevWeekStart = shiftDate(weekStart, -7);
const prevWeekEnd = shiftDate(weekEnd, -7);
// ---------------- MONTH (rolling 5 weeks, MonSun aligned) ---------------- // ---------------- WEEKLY ----------------
const monthBuckets: { const weeklyRolling: any[] = [];
label: string; const weeklyCalendar: any[] = [];
start: Date;
end: Date;
amount: number;
}[] = [];
const currentWeekStart = getStartOfWeek(now); const currentWeekStart = getStartOfWeek(now);
for (let i = 0; i < 5; i++) { // rolling (last 5 weeks)
for (let i = 4; i >= 0; i--) {
const start = new Date(currentWeekStart.getTime() - i * 7 * 86400000); const start = new Date(currentWeekStart.getTime() - i * 7 * 86400000);
const end = endOfDay(new Date(start.getTime() + 6 * 86400000)); const end = endOfDay(new Date(start.getTime() + 6 * 86400000));
monthBuckets.push({ weeklyRolling.push({
label: `${format(start)} - ${format(end)}`, label: `${format(start)} - ${format(end)}`,
start, start,
end, end,
amount: 0 amount: 0,
compare: 0,
prevStart: shiftDate(start, -7),
prevEnd: shiftDate(end, -7)
}); });
} }
// ---------------- YEAR (rolling 12 months) ---------------- // calendar weeks
const yearBuckets: { const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
label: string; const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
start: Date;
end: Date;
amount: number;
}[] = [];
for (let i = 0; i < 12; i++) { const firstWeekStart = getStartOfWeek(startOfMonth);
const totalWeeks = Math.ceil(
(endOfMonth.getTime() - firstWeekStart.getTime()) / (7 * 86400000)
) + 1;
for (let i = 0; i < totalWeeks; i++) {
const start = new Date(firstWeekStart.getTime() + i * 7 * 86400000);
const end = endOfDay(new Date(start.getTime() + 6 * 86400000));
weeklyCalendar.push({
label: `${format(start)} - ${format(end)}`,
start,
end,
amount: 0,
compare: 0,
prevStart: shiftDate(start, -7),
prevEnd: shiftDate(end, -7)
});
}
// ---------------- MONTHLY ----------------
const monthlyRolling: any[] = [];
const monthlyCalendar: any[] = [];
// rolling (last 12 months)
for (let i = 11; i >= 0; i--) {
const d = new Date(now); const d = new Date(now);
d.setMonth(d.getMonth() - i); d.setMonth(d.getMonth() - i);
const start = new Date(d.getFullYear(), d.getMonth(), 1); const start = new Date(d.getFullYear(), d.getMonth(), 1);
const end = const end =
i === 0 i === 0
? endOfDay(now) // current month → till now ? endOfDay(now)
: endOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0)); : endOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0));
const label = `${d.toLocaleString("default", { const prevStart = new Date(start);
month: "short" prevStart.setMonth(prevStart.getMonth() - 1);
})}-${String(d.getFullYear()).slice(2)}`; const prevEnd = new Date(end);
prevEnd.setMonth(prevEnd.getMonth() - 1);
yearBuckets.push({ monthlyRolling.push({
label, label: `${d.toLocaleString("default", { month: "short" })}-${String(
d.getFullYear()
).slice(2)}`,
start, start,
end, end,
amount: 0 amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
// calendar (JanDec)
for (let i = 0; i < 12; i++) {
const start = new Date(now.getFullYear(), i, 1);
const end = endOfDay(new Date(now.getFullYear(), i + 1, 0));
const prevStart = new Date(start);
prevStart.setFullYear(prevStart.getFullYear() - 1);
const prevEnd = new Date(end);
prevEnd.setFullYear(prevEnd.getFullYear() - 1);
monthlyCalendar.push({
label: `${start.toLocaleString("default", { month: "short" })}-${String(
start.getFullYear()
).slice(2)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
}); });
} }
@@ -173,50 +243,72 @@ export async function fetchAggregatedData(
const payee = item.payee?.name || item.payee || "Unknown"; const payee = item.payee?.name || item.payee || "Unknown";
payeeMap[payee] = (payeeMap[payee] || 0) + amt; payeeMap[payee] = (payeeMap[payee] || 0) + amt;
// WEEK // DAILY
if (d >= weekStart && d <= weekEnd) { if (d >= weekStart && d <= weekEnd) {
const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()]; const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()];
if (weekBuckets[day] !== undefined) { if (dailyBuckets[day]) {
weekBuckets[day] += amt; dailyBuckets[day].amount += amt;
} }
} }
// MONTH (rolling weeks) if (d >= prevWeekStart && d <= prevWeekEnd) {
for (const b of monthBuckets) { const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()];
if (d >= b.start && d <= b.end) { if (dailyBuckets[day]) {
b.amount += amt; dailyBuckets[day].compare += amt;
} }
} }
// YEAR (rolling months) // WEEKLY
for (const b of yearBuckets) { for (const b of weeklyRolling) {
if (d >= b.start && d <= b.end) { if (d >= b.start && d <= b.end) b.amount += amt;
b.amount += amt; if (d >= b.prevStart && d <= b.prevEnd) b.compare += amt;
} }
for (const b of weeklyCalendar) {
if (d >= b.start && d <= b.end) b.amount += amt;
if (d >= b.prevStart && d <= b.prevEnd) b.compare += amt;
}
// MONTHLY
for (const b of monthlyRolling) {
if (d >= b.start && d <= b.end) b.amount += amt;
if (d >= b.prevStart && d <= b.prevEnd) b.compare += amt;
}
for (const b of monthlyCalendar) {
if (d >= b.start && d <= b.end) b.amount += amt;
if (d >= b.prevStart && d <= b.prevEnd) b.compare += amt;
} }
} }
const toPoints = (b: any): ChartDataPoint[] => const toPoints = (arr: any[]): ChartDataPoint[] =>
Array.isArray(b) arr.map((x) => ({
? b.map((x) => ({ id: x.label,
id: x.label, amount: x.amount,
amount: x.amount compareAmount: x.compare
})) }));
: Object.entries(b).map(([k, v]: any) => ({
id: k,
amount: v
}));
const chartData = { const chartData: ChartData = {
week: toPoints(weekBuckets), daily: Object.entries(dailyBuckets).map(([k, v]: any) => ({
month: toPoints(monthBuckets), id: k,
year: toPoints(yearBuckets) amount: v.amount,
compareAmount: v.compare
})),
weekly: {
rolling: toPoints(weeklyRolling),
calendar: toPoints(weeklyCalendar)
},
monthly: {
rolling: toPoints(monthlyRolling),
calendar: toPoints(monthlyCalendar)
}
}; };
// highlight max // highlight max (current only)
Object.values(chartData).forEach(group => { Object.values(chartData).forEach((group: any) => {
let max = group[0]; const arr = Array.isArray(group) ? group : group.rolling;
for (const g of group) { if (!arr?.length) return;
let max = arr[0];
for (const g of arr) {
if (g.amount > max.amount) max = g; if (g.amount > max.amount) max = g;
} }
if (max.amount > 0) max.highlighted = true; if (max.amount > 0) max.highlighted = true;