diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index bc0ace9..9458b93 100644 --- a/src/Dashboard.tsx +++ b/src/Dashboard.tsx @@ -11,12 +11,14 @@ import { import LatestItemsList, { LatestItem } from "./components/LatestItemsList"; import HistoryChart from "./components/HistoryChart"; +import { + AggregatedDashboardData, +} from "./components/HistoryChart"; import { fetchLatestTransactions, fetchAggregatedExpenses, fetchAggregatedIncome, - AggregatedDashboardData } from "./utils/dashboardLoader"; export default function Dashboard() { diff --git a/src/components/HistoryChart.tsx b/src/components/HistoryChart.tsx index 5759c78..aca864e 100644 --- a/src/components/HistoryChart.tsx +++ b/src/components/HistoryChart.tsx @@ -31,6 +31,13 @@ export interface HistoryChartProps { setComparison: (mode: boolean) => void; } +export interface AggregatedDashboardData { + chartData: ChartData; + totalAmount: number; + topPayees: Array<{ payeeName: string; amount: number }>; +} + + export default function HistoryChart({ header, summary, diff --git a/src/utils/dashboardLoader.ts b/src/utils/dashboardLoader.ts index 9c4cc14..5db42c3 100644 --- a/src/utils/dashboardLoader.ts +++ b/src/utils/dashboardLoader.ts @@ -4,38 +4,18 @@ import { ChartDataPoint } from "../components/HistoryChart"; import * as React from "react"; import MonetizationOnIcon from "@mui/icons-material/MonetizationOn"; -// ---------------- ICON ---------------- +import { + buildDailyBuckets, + buildWeeklyRolling, + buildWeeklyCalendar, + buildMonthlyRolling, + buildMonthlyCalendar +} from "./periodBuilders"; + const DEFAULT_ICON = React.createElement(MonetizationOnIcon, { sx: { color: "#388e3c" } }); -// ---------------- HELPERS ---------------- -const format = (d: Date) => - `${d.getDate()} ${d.toLocaleString("default", { month: "short" })}`; - -const startOfDay = (d: Date) => { - const x = new Date(d); - x.setHours(0, 0, 0, 0); - return x; -}; - -const endOfDay = (d: Date) => { - const x = new Date(d); - x.setHours(23, 59, 59, 999); - return x; -}; - -const getStartOfWeek = (d: Date) => { - const date = new Date(d); - const day = date.getDay() || 7; - if (day !== 1) date.setDate(date.getDate() - (day - 1)); - return startOfDay(date); -}; - -const shiftDate = (d: Date, days: number) => - new Date(d.getTime() + days * 86400000); - -// ---------------- LATEST ---------------- export async function fetchLatestTransactions( type: "expense" | "income" ): Promise { @@ -66,35 +46,17 @@ export async function fetchLatestTransactions( iconBgColor: type === "expense" ? "#ffebee" : "#e8f5e9", title: exp.payee?.name || exp.payee || "Unknown Payee", - subtitle: exp.category?.name || exp.account?.name || "Transaction", + subtitle: + exp.category?.name || exp.account?.name || "Transaction", amount: `Rs ${Math.abs(exp.amount || 0)}`, timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago` }; }); } -// ---------------- TYPES ---------------- -export interface ChartSeries { - rolling: ChartDataPoint[]; - calendar: ChartDataPoint[]; -} - -export interface ChartData { - daily: ChartDataPoint[]; - weekly: ChartSeries; - monthly: ChartSeries; -} - -export interface AggregatedDashboardData { - chartData: ChartData; - totalAmount: number; - topPayees: Array<{ payeeName: string; amount: number }>; -} - -// ---------------- AGGREGATION ---------------- export async function fetchAggregatedData( type: "expense" | "income" -): Promise { +) { const res = await api.get("/expenses", { params: { limit: 0 } }); const all: any[] = res.data?.items || res.data || []; @@ -108,126 +70,19 @@ export async function fetchAggregatedData( const normalize = (amt: number) => Math.abs(amt); - // ---------------- DAILY ---------------- - const dailyBuckets: Record = { - Mon: { amount: 0, compare: 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 { + buckets: dailyBuckets, + weekStart, + weekEnd, + prevWeekStart, + prevWeekEnd + } = buildDailyBuckets(now); - const weekStart = getStartOfWeek(now); - const weekEnd = endOfDay(new Date(weekStart.getTime() + 6 * 86400000)); - const prevWeekStart = shiftDate(weekStart, -7); - const prevWeekEnd = shiftDate(weekEnd, -7); + const weeklyRolling = buildWeeklyRolling(now); + const weeklyCalendar = buildWeeklyCalendar(now); + const monthlyRolling = buildMonthlyRolling(now); + const monthlyCalendar = buildMonthlyCalendar(now); - // ---------------- WEEKLY ---------------- - const weeklyRolling: any[] = []; - const weeklyCalendar: any[] = []; - - const currentWeekStart = getStartOfWeek(now); - - // rolling (last 5 weeks) - for (let i = 4; i >= 0; i--) { - const start = new Date(currentWeekStart.getTime() - i * 7 * 86400000); - const end = endOfDay(new Date(start.getTime() + 6 * 86400000)); - - weeklyRolling.push({ - label: `${format(start)} - ${format(end)}`, - start, - end, - amount: 0, - compare: 0, - prevStart: shiftDate(start, -7), - prevEnd: shiftDate(end, -7) - }); - } - - // calendar weeks - const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); - const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); - - 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); - d.setMonth(d.getMonth() - i); - - const start = new Date(d.getFullYear(), d.getMonth(), 1); - const end = - i === 0 - ? endOfDay(now) - : endOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0)); - - const prevStart = new Date(start); - prevStart.setMonth(prevStart.getMonth() - 1); - const prevEnd = new Date(end); - prevEnd.setMonth(prevEnd.getMonth() - 1); - - monthlyRolling.push({ - label: `${d.toLocaleString("default", { month: "short" })}-${String( - d.getFullYear() - ).slice(2)}`, - start, - end, - amount: 0, - compare: 0, - prevStart, - prevEnd - }); - } - - // calendar (Jan–Dec) - 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 - }); - } - - // ---------------- LOOP ---------------- for (const item of all) { const d = new Date( item.occurred_at || item.created_at || Date.now() @@ -237,46 +92,37 @@ export async function fetchAggregatedData( if (!isValid(amtRaw)) continue; const amt = normalize(amtRaw); - totalAmount += amt; const payee = item.payee?.name || item.payee || "Unknown"; payeeMap[payee] = (payeeMap[payee] || 0) + amt; - // DAILY + const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()]; + if (d >= weekStart && d <= weekEnd) { - const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()]; if (dailyBuckets[day]) { dailyBuckets[day].amount += amt; } } if (d >= prevWeekStart && d <= prevWeekEnd) { - const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()]; if (dailyBuckets[day]) { dailyBuckets[day].compare += amt; } } - // WEEKLY - for (const b of weeklyRolling) { - if (d >= b.start && d <= b.end) 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; - } + const apply = (arr: any[]) => { + for (const b of arr) { + 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; - } + apply(weeklyRolling); + apply(weeklyCalendar); + apply(monthlyRolling); + apply(monthlyCalendar); } const toPoints = (arr: any[]): ChartDataPoint[] => @@ -286,7 +132,7 @@ export async function fetchAggregatedData( compareAmount: x.compare })); - const chartData: ChartData = { + const chartData = { daily: Object.entries(dailyBuckets).map(([k, v]: any) => ({ id: k, amount: v.amount, @@ -302,7 +148,6 @@ export async function fetchAggregatedData( } }; - // highlight max (current only) Object.values(chartData).forEach((group: any) => { const arr = Array.isArray(group) ? group : group.rolling; if (!arr?.length) return; @@ -322,7 +167,6 @@ export async function fetchAggregatedData( return { chartData, totalAmount, topPayees }; } -// ---------------- EXPORTS ---------------- export const fetchAggregatedExpenses = () => fetchAggregatedData("expense"); diff --git a/src/utils/dateUtils.ts b/src/utils/dateUtils.ts new file mode 100644 index 0000000..0652304 --- /dev/null +++ b/src/utils/dateUtils.ts @@ -0,0 +1,33 @@ +export const format = (d: Date) => + `${d.getDate()} ${d.toLocaleString("default", { month: "short" })}`; + +export const startOfDay = (d: Date) => { + const x = new Date(d); + x.setHours(0, 0, 0, 0); + return x; +}; + +export const endOfDay = (d: Date) => { + const x = new Date(d); + x.setHours(23, 59, 59, 999); + return x; +}; + +export const getStartOfWeek = (d: Date) => { + const date = new Date(d); + const day = date.getDay() || 7; + if (day !== 1) date.setDate(date.getDate() - (day - 1)); + return startOfDay(date); +}; + +export const shiftDate = (d: Date, days: number) => + new Date(d.getTime() + days * 86400000); + +export const getWeekIndex = (date: Date) => { + const firstDay = new Date(date.getFullYear(), date.getMonth(), 1); + const firstWeekStart = getStartOfWeek(firstDay); + return Math.floor( + (startOfDay(date).getTime() - firstWeekStart.getTime()) / + (7 * 86400000) + ); +}; diff --git a/src/utils/periodBuilders.ts b/src/utils/periodBuilders.ts new file mode 100644 index 0000000..daa444e --- /dev/null +++ b/src/utils/periodBuilders.ts @@ -0,0 +1,178 @@ +import { + format, + endOfDay, + getStartOfWeek, + shiftDate, + getWeekIndex +} from "./dateUtils"; + +export const buildDailyBuckets = (now: Date) => { + const buckets: Record = { + Mon: { amount: 0, compare: 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 weekEnd = endOfDay(new Date(weekStart.getTime() + 6 * 86400000)); + const prevWeekStart = shiftDate(weekStart, -7); + const prevWeekEnd = shiftDate(weekEnd, -7); + + return { buckets, weekStart, weekEnd, prevWeekStart, prevWeekEnd }; +}; + +const getPrevMonthWeek = (start: Date) => { + const prevMonthDate = new Date(start); + prevMonthDate.setMonth(prevMonthDate.getMonth() - 1); + + const prevMonthFirst = new Date( + prevMonthDate.getFullYear(), + prevMonthDate.getMonth(), + 1 + ); + + const prevFirstWeekStart = getStartOfWeek(prevMonthFirst); + const weekIndex = getWeekIndex(start); + + const prevStart = new Date( + prevFirstWeekStart.getTime() + weekIndex * 7 * 86400000 + ); + const prevEnd = endOfDay(new Date(prevStart.getTime() + 6 * 86400000)); + + return { prevStart, prevEnd }; +}; + +export const buildWeeklyRolling = (now: Date) => { + const arr: any[] = []; + const currentWeekStart = getStartOfWeek(now); + + for (let i = 4; i >= 0; i--) { + const start = new Date( + currentWeekStart.getTime() - i * 7 * 86400000 + ); + const end = endOfDay(new Date(start.getTime() + 6 * 86400000)); + + const { prevStart, prevEnd } = getPrevMonthWeek(start); + + arr.push({ + label: `${format(start)} - ${format(end)}`, + start, + end, + amount: 0, + compare: 0, + prevStart, + prevEnd + }); + } + + return arr; +}; + +export const buildWeeklyCalendar = (now: Date) => { + const arr: any[] = []; + + const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1); + const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0); + 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)); + + const { prevStart, prevEnd } = getPrevMonthWeek(start); + + arr.push({ + label: `${format(start)} - ${format(end)}`, + start, + end, + amount: 0, + compare: 0, + prevStart, + prevEnd + }); + } + + return arr; +}; + +export const buildMonthlyRolling = (now: Date) => { + const arr: any[] = []; + + for (let i = 11; i >= 0; i--) { + const d = new Date(now); + d.setMonth(d.getMonth() - i); + + const start = new Date(d.getFullYear(), d.getMonth(), 1); + const end = + i === 0 + ? endOfDay(now) + : endOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0)); + + const prevStart = new Date(start); + prevStart.setFullYear(prevStart.getFullYear() - 1); + + let prevEnd = new Date(end); + prevEnd.setFullYear(prevEnd.getFullYear() - 1); + + if (i === 0) { + prevEnd = new Date(prevStart); + prevEnd.setDate(now.getDate()); + prevEnd = endOfDay(prevEnd); + } + + arr.push({ + label: `${d.toLocaleString("default", { + month: "short" + })}-${String(d.getFullYear()).slice(2)}`, + start, + end, + amount: 0, + compare: 0, + prevStart, + prevEnd + }); + } + + return arr; +}; + +export const buildMonthlyCalendar = (now: Date) => { + const arr: any[] = []; + + 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); + + arr.push({ + label: `${start.toLocaleString("default", { + month: "short" + })}-${String(start.getFullYear()).slice(2)}`, + start, + end, + amount: 0, + compare: 0, + prevStart, + prevEnd + }); + } + + return arr; +};