From 77b60ba073923760c06c57c81136b7a69179f1fc Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 9 May 2026 13:00:42 +0000 Subject: [PATCH] items-by-period (#2) Reviewed-on: https://git.aetoskia.com/apps/khata-ui/pulls/2 Co-authored-by: Vishesh 'ironeagle' Bangotra Co-committed-by: Vishesh 'ironeagle' Bangotra --- src/components/Dashboard/Dashboard.models.ts | 4 +- src/components/Dashboard/Dashboard.tsx | 6 + src/components/Dashboard/Dashboard.view.tsx | 7 +- .../HistoryChart/HistoryChart.adapter.ts | 75 +++++++++ src/components/HistoryChart/HistoryChart.tsx | 128 +-------------- .../LatestItems/LatestItems.adapter.ts | 66 ++++++++ .../LatestItems/LatestItems.models.ts | 10 +- src/components/LatestItems/LatestItems.tsx | 140 +++++------------ .../LatestItems/LatestItems.view.tsx | 90 ++++++++++- .../ProgressCard/ProgressCard.models.ts | 2 + src/components/ProgressCard/ProgressCard.tsx | 4 +- .../ProgressCard/ProgressCard.utils.ts | 15 -- .../ProgressCard/ProgressCard.view.tsx | 22 ++- .../ProgressCard/TopTags.adapter.ts | 74 +++++++++ src/components/ProgressCard/TopTags.tsx | 106 ++++--------- src/components/report.helpers.ts | 147 ++++++++++++++++++ src/dashboard-config.ts | 20 ++- src/features/report/index.ts | 2 + src/features/report/report.utils.ts | 13 +- 19 files changed, 574 insertions(+), 357 deletions(-) create mode 100644 src/components/HistoryChart/HistoryChart.adapter.ts create mode 100644 src/components/LatestItems/LatestItems.adapter.ts delete mode 100644 src/components/ProgressCard/ProgressCard.utils.ts create mode 100644 src/components/ProgressCard/TopTags.adapter.ts create mode 100644 src/components/report.helpers.ts diff --git a/src/components/Dashboard/Dashboard.models.ts b/src/components/Dashboard/Dashboard.models.ts index e801720..58b828d 100644 --- a/src/components/Dashboard/Dashboard.models.ts +++ b/src/components/Dashboard/Dashboard.models.ts @@ -1,6 +1,7 @@ import * as React from "react"; import { - ReportData + ReportData, + GroupKey, } from "../../features/report"; export type DashboardMode = "expense" | "income"; @@ -11,6 +12,7 @@ export interface DashboardState { mode: DashboardMode; periodType: DashboardPeriodType; selectedPeriodId: DashboardSelectedPeriodId; + selectedGroupKey: GroupKey | null; comparison: boolean; } diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx index 4dc0580..e7b6cbb 100644 --- a/src/components/Dashboard/Dashboard.tsx +++ b/src/components/Dashboard/Dashboard.tsx @@ -7,6 +7,7 @@ export default function Dashboard(props: DashboardProps) { mode: "expense", periodType: "rolling", selectedPeriodId: null, + selectedGroupKey: null, comparison: false, }); @@ -35,6 +36,10 @@ export default function Dashboard(props: DashboardProps) { setState(prev => ({ ...prev, selectedPeriodId })); }; + const setSelectedGroupKey = (groupKey: typeof state.selectedGroupKey) => { + setState(prev => ({ ...prev, selectedGroupKey: groupKey })); + }; + return ( ); } diff --git a/src/components/Dashboard/Dashboard.view.tsx b/src/components/Dashboard/Dashboard.view.tsx index e4aeae9..55793e2 100644 --- a/src/components/Dashboard/Dashboard.view.tsx +++ b/src/components/Dashboard/Dashboard.view.tsx @@ -8,6 +8,7 @@ import { ToggleButtonGroup } from "@mui/material"; import { useTheme, alpha } from "@mui/material/styles"; +import { GroupKey } from "../../features/report"; import { DashboardProps, DashboardState } from "./Dashboard.models"; interface ViewProps extends DashboardProps { @@ -16,6 +17,7 @@ interface ViewProps extends DashboardProps { toggleMode: () => void; togglePeriodType: () => void; setSelectedPeriodId: (id: string | null) => void; + setSelectedGroupKey: (groupKey: GroupKey | null) => void; toggleComparison: () => void; } @@ -28,10 +30,11 @@ export default function DashboardView({ togglePeriodType, toggleComparison, setSelectedPeriodId, + setSelectedGroupKey, }: ViewProps) { const theme = useTheme(); const themeMode = theme.palette.mode; - const { mode, periodType, comparison, selectedPeriodId } = state; + const { mode, periodType, comparison, selectedPeriodId, selectedGroupKey } = state; // Resolve colors with fallbacks const colors = React.useMemo(() => { @@ -120,10 +123,12 @@ export default function DashboardView({ periodType={periodType} comparison={comparison} selectedPeriodId={selectedPeriodId} + selectedGroupKey={selectedGroupKey} togglePeriodType={togglePeriodType} toggleComparison={toggleComparison} setSelectedPeriodId={setSelectedPeriodId} + setSelectedGroupKey={setSelectedGroupKey} /> ); diff --git a/src/components/HistoryChart/HistoryChart.adapter.ts b/src/components/HistoryChart/HistoryChart.adapter.ts new file mode 100644 index 0000000..8f17dca --- /dev/null +++ b/src/components/HistoryChart/HistoryChart.adapter.ts @@ -0,0 +1,75 @@ +import { ReportData } from "../../features/report"; +import { + mergeBucketPeriods, + getAmount, + PeriodKey, +} from "../report.helpers"; +import { ChartDataPoint } from "./HistoryChart.models"; + +// ─── Tab → PeriodKey ───────────────────────────────────────── + +const TAB_TO_KEY: Record = { + Weekly: "weekly", + Monthly: "monthly", + Yearly: "yearly", + "Financial Year": "fyly", + "All Time": "full", +}; + +export function tabToKey(tab: string): PeriodKey { + return TAB_TO_KEY[tab] ?? "full"; +} + +// ─── Comparison ────────────────────────────────────────────── + +function attachComparison( + points: ChartDataPoint[], + key: PeriodKey +): ChartDataPoint[] { + const getCompareIndex = (i: number) => { + if (key === "weekly") return i - 4; + if (key === "monthly") return i - 12; + if (key === "yearly") return i - 1; + if (key === "fyly") return i - 1; + return -1; + }; + + return points.map((p, i) => { + const ci = getCompareIndex(i); + + return { + ...p, + compare: + ci >= 0 && points[ci] + ? { + id: points[ci].id, + label: points[ci].label, + amount: points[ci].amount, + } + : undefined, + }; + }); +} + +// ─── Main adapter ──────────────────────────────────────────── + +export function buildChartData( + reportData: ReportData, + key: PeriodKey, + mode: "expense" | "income", + comparison: boolean +): ChartDataPoint[] { + const merged = mergeBucketPeriods(reportData.buckets, key); + + let points: ChartDataPoint[] = merged.map((p) => ({ + id: p.id, + label: p.label, + amount: getAmount(p, mode), + })); + + if (comparison) { + points = attachComparison(points, key); + } + + return points; +} diff --git a/src/components/HistoryChart/HistoryChart.tsx b/src/components/HistoryChart/HistoryChart.tsx index 4488676..09f8124 100644 --- a/src/components/HistoryChart/HistoryChart.tsx +++ b/src/components/HistoryChart/HistoryChart.tsx @@ -1,133 +1,13 @@ import * as React from "react"; -import { HistoryChartProps, ChartDataPoint } from "./HistoryChart.models"; +import { HistoryChartProps } from "./HistoryChart.models"; import HistoryChartView from "./HistoryChart.view"; -import { ReportPeriod } from "../../features/report"; - -type DecoratedPeriod = ReportPeriod & { - id: string; - label: string; -}; - -const TAB_TO_KEY: Record = { - Weekly: "weekly", - Monthly: "monthly", - Yearly: "yearly", - 'Financial Year': "fyly", - 'All Time': "full" -}; - -function getAmount(p: ReportPeriod, mode: "expense" | "income") { - return mode === "expense" ? p.expenses.sum : p.incomes.sum; -} - -function mergeMetric(a: any, b: any) { - const sum = a.sum + b.sum; - const count = a.count + b.count; - - return { - ...a, - sum, - count, - average: count > 0 ? sum / count : 0, - transactions: a.transactions || b.transactions - ? [ - ...(a.transactions || []), - ...(b.transactions || []) - ] - : undefined - }; -} - -function mergeBuckets( - buckets: any[], - key: "weekly" | "monthly" | "yearly" | "fyly" | "full" -): DecoratedPeriod[] { - const map = new Map(); - - for (const bucket of buckets) { - const periods = (bucket.periods[key] || []) as DecoratedPeriod[]; - - for (const p of periods) { - const existing = map.get(p.id); - - if (!existing) { - map.set(p.id, { - ...p, - expenses: { ...p.expenses }, - incomes: { ...p.incomes } - }); - } else { - map.set(p.id, { - ...existing, - expenses: mergeMetric(existing.expenses, p.expenses), - incomes: mergeMetric(existing.incomes, p.incomes) - }); - } - } - } - - return Array.from(map.values()).sort( - (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime() - ); -} - -function attachComparison( - points: ChartDataPoint[], - key: "weekly" | "monthly" | "yearly" | "fyly" | "full" -): ChartDataPoint[] { - const getCompareIndex = (i: number) => { - if (key === "weekly") return i - 4; - if (key === "monthly") return i - 12; - if (key === "yearly") return i - 1; - if (key === "fyly") return i - 1; - return -1; - }; - - return points.map((p, i) => { - const ci = getCompareIndex(i); - - return { - ...p, - compare: - ci >= 0 && points[ci] - ? { - id: points[ci].id, - label: points[ci].label, - amount: points[ci].amount - } - : undefined - }; - }); -} - -function buildChartData( - reportData: HistoryChartProps["reportData"], - key: "weekly" | "monthly" | "yearly" | "fyly" | "full", - mode: "expense" | "income", - comparison: boolean -): ChartDataPoint[] { - const merged = mergeBuckets(reportData.buckets, key); - console.log("Merged periods:", merged); - - let points: ChartDataPoint[] = merged.map((p) => ({ - id: p.id, - label: p.label, - amount: getAmount(p, mode) - })); - - if (comparison) { - points = attachComparison(points, key); - } - - return points; -} +import { buildChartData, tabToKey } from "./HistoryChart.adapter"; export default function HistoryChart(props: HistoryChartProps) { const { tabs, reportData, mode, - periodType, comparison, selectedPeriodId, setSelectedPeriodId @@ -136,7 +16,7 @@ export default function HistoryChart(props: HistoryChartProps) { const [activeTab, setActiveTab] = React.useState(tabs[0] || ""); const [startIndex, setStartIndex] = React.useState(0); - const activeDataKey = TAB_TO_KEY[activeTab]; + const activeDataKey = tabToKey(activeTab); const currentData = React.useMemo(() => { return buildChartData(reportData, activeDataKey, mode, comparison); @@ -184,7 +64,7 @@ export default function HistoryChart(props: HistoryChartProps) { React.useEffect(() => { setSelectedPeriodId(null); - }, [activeTab, periodType]); + }, [activeTab]); React.useEffect(() => { if ( diff --git a/src/components/LatestItems/LatestItems.adapter.ts b/src/components/LatestItems/LatestItems.adapter.ts new file mode 100644 index 0000000..d8236ee --- /dev/null +++ b/src/components/LatestItems/LatestItems.adapter.ts @@ -0,0 +1,66 @@ +import { ReportData, Transaction, GroupKey } from "../../features/report"; +import { + mergeBucketPeriods, + periodIdToKey, + formatCurrency, + filterBuckets, +} from "../report.helpers"; +import { LatestItem } from "./LatestItems.models"; + +// ─── Transaction extraction ───────────────────────────────── + +function extractTransactions( + reportData: ReportData, + selectedPeriodId: string | null, + selectedGroupKey: GroupKey | null, + mode: "expense" | "income" +): Transaction[] { + const buckets = filterBuckets(reportData.buckets, selectedGroupKey); + if (selectedPeriodId) { + const key = periodIdToKey(selectedPeriodId); + const periods = mergeBucketPeriods(buckets, key); + const selected = periods.find((p) => p.id === selectedPeriodId); + + if (!selected) return []; + + return mode === "expense" + ? (selected.expenses.transactions || []) + : (selected.incomes.transactions || []); + } + + const periods = mergeBucketPeriods(buckets, "full"); + + if (!periods.length) return []; + + const full = periods[0]; + + return mode === "expense" + ? (full.expenses.transactions || []) + : (full.incomes.transactions || []); +} + +// ─── Main adapter ──────────────────────────────────────────── + +export function buildLatestItems( + reportData: ReportData, + selectedPeriodId: string | null, + selectedGroupKey: GroupKey | null, + mode: "expense" | "income" +): LatestItem[] { + const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode); + + return txns + .filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0)) + .sort( + (a, b) => + new Date(b.occurred_at).getTime() - + new Date(a.occurred_at).getTime() + ) + .map((t, index) => ({ + id: index + 1, + title: t.payee.name, + subtitle: t.tags.map((tag) => tag.name).join(", "), + amount: formatCurrency(t.amount), + timeAgo: new Date(t.occurred_at).toLocaleDateString("en-IN"), + })); +} diff --git a/src/components/LatestItems/LatestItems.models.ts b/src/components/LatestItems/LatestItems.models.ts index bd15502..d953bdf 100644 --- a/src/components/LatestItems/LatestItems.models.ts +++ b/src/components/LatestItems/LatestItems.models.ts @@ -1,18 +1,14 @@ -import * as React from "react"; - export interface LatestItem { id: string | number; - icon: React.ReactNode; - iconBgColor?: string; title: string; subtitle: string; amount: string; timeAgo: string; } -export interface LatestItemsListProps { - title?: string; +export interface LatestItemsViewProps { items: LatestItem[]; - onViewAll?: () => void; accentColor: string; + canExpand: boolean; + onExpand: () => void; } diff --git a/src/components/LatestItems/LatestItems.tsx b/src/components/LatestItems/LatestItems.tsx index 9c9f247..99c4712 100644 --- a/src/components/LatestItems/LatestItems.tsx +++ b/src/components/LatestItems/LatestItems.tsx @@ -1,112 +1,44 @@ import * as React from "react"; -import { - List, - ListItem, - ListItemAvatar, - ListItemText, - Avatar, - Typography, - Box, - Button, -} from "@mui/material"; +import { ReportData, GroupKey } from "../../features/report"; +import { buildLatestItems } from "./LatestItems.adapter"; +import LatestItemsView from "./LatestItems.view"; -export interface LatestItem { - id: string | number; - icon: React.ReactNode; - iconBgColor?: string; - title: string; - subtitle: string; - amount: string; - timeAgo: string; -} - -export interface LatestItemsListProps { - title?: string; - items: LatestItem[]; - onViewAll?: () => void; - accentColor: any; -} +type Props = { + reportData: ReportData; + mode: "expense" | "income"; + selectedPeriodId: string | null; + selectedGroupKey?: GroupKey | null; + accentColor: string; +}; export default function LatestItems({ - title = "Recent Transactions", - items, - onViewAll, + reportData, + mode, + selectedPeriodId, + selectedGroupKey = null, accentColor, -}: LatestItemsListProps) { +}: Props) { + const [visibleCount, setVisibleCount] = React.useState(5); + + const allItems = React.useMemo(() => { + return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, mode); + }, [reportData, selectedPeriodId, selectedGroupKey, mode]); + + const hasSelection = Boolean(selectedPeriodId) || Boolean(selectedGroupKey); + + const visibleItems = React.useMemo(() => { + if (!hasSelection) return allItems.slice(0, 5); + return allItems.slice(0, visibleCount); + }, [allItems, hasSelection, visibleCount]); + + const canExpand = hasSelection && visibleCount < allItems.length; + return ( - - {/* Header */} - - - {title} - - {onViewAll && ( - - )} - - - {/* List */} - - {items.map((item, index) => ( - - - - {item.icon} - - - - - {item.title} - - } - secondary={ - - {item.subtitle} - - } - /> - - - - {item.amount} - - - {item.timeAgo} - - - - ))} - - + setVisibleCount((prev) => prev + 5)} + /> ); } diff --git a/src/components/LatestItems/LatestItems.view.tsx b/src/components/LatestItems/LatestItems.view.tsx index 71a7983..f29a6ae 100644 --- a/src/components/LatestItems/LatestItems.view.tsx +++ b/src/components/LatestItems/LatestItems.view.tsx @@ -1,6 +1,88 @@ -import LatestItemsListView from "./LatestItems.view"; -import { LatestItemsListProps } from "./LatestItems.models"; +import * as React from "react"; +import { + List, + ListItem, + ListItemAvatar, + ListItemText, + Avatar, + Typography, + Box, + IconButton, +} from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { LatestItemsViewProps } from "./LatestItems.models"; -export default function LatestItemsList(props: LatestItemsListProps) { - return ; +export default function LatestItemsView({ + items, + accentColor, + canExpand, + onExpand, +}: LatestItemsViewProps) { + return ( + + + + Recent Transactions + + + + + {items.map((item, index) => ( + + + + + + + {item.title} + + } + secondary={ + + {item.subtitle} + + } + /> + + + + {item.amount} + + + {item.timeAgo} + + + + ))} + + {canExpand && ( + + + + + + )} + + + ); } diff --git a/src/components/ProgressCard/ProgressCard.models.ts b/src/components/ProgressCard/ProgressCard.models.ts index 2815bf4..c50984c 100644 --- a/src/components/ProgressCard/ProgressCard.models.ts +++ b/src/components/ProgressCard/ProgressCard.models.ts @@ -5,4 +5,6 @@ export interface ProgressCardProps { totalAmount: number; colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning"; compact?: boolean; + selected?: boolean; + onClick?: () => void; } diff --git a/src/components/ProgressCard/ProgressCard.tsx b/src/components/ProgressCard/ProgressCard.tsx index 3441a2f..b42c2e4 100644 --- a/src/components/ProgressCard/ProgressCard.tsx +++ b/src/components/ProgressCard/ProgressCard.tsx @@ -1,7 +1,7 @@ import * as React from "react"; import ProgressCardView from "./ProgressCard.view"; import { ProgressCardProps } from "./ProgressCard.models"; -import { getPercentage, formatCurrency } from "./ProgressCard.utils"; +import { getPercentage, formatCurrency } from "../report.helpers"; export default function ProgressCard(props: ProgressCardProps) { const { progressAmount, totalAmount, compact = false } = props; @@ -18,6 +18,8 @@ export default function ProgressCard(props: ProgressCardProps) { formattedProgress={formattedProgress} formattedTotal={formattedTotal} compact={compact} + selected={props.selected} + onClick={props.onClick} /> ); } diff --git a/src/components/ProgressCard/ProgressCard.utils.ts b/src/components/ProgressCard/ProgressCard.utils.ts deleted file mode 100644 index de50ef5..0000000 --- a/src/components/ProgressCard/ProgressCard.utils.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const getPercentage = (progressAmount: number, totalAmount: number) => { - if (!totalAmount) return 0; - return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100)); -}; - -export const formatCurrency = (val: number) => { - const absVal = Math.abs(val); - if (absVal >= 100000) { - return `₹ ${(val / 100000).toFixed(2)}L`; - } - if (absVal >= 1000) { - return `₹ ${(val / 1000).toFixed(2)}k`; - } - return `₹ ${val.toFixed(2)}`; -}; diff --git a/src/components/ProgressCard/ProgressCard.view.tsx b/src/components/ProgressCard/ProgressCard.view.tsx index 7f784fe..172d93b 100644 --- a/src/components/ProgressCard/ProgressCard.view.tsx +++ b/src/components/ProgressCard/ProgressCard.view.tsx @@ -14,6 +14,8 @@ interface ViewProps extends ProgressCardProps { percentage: number; formattedProgress: string; formattedTotal: string; + selected?: boolean; + onClick?: () => void; } export default function ProgressCardView({ @@ -23,6 +25,8 @@ export default function ProgressCardView({ formattedProgress, formattedTotal, compact = false, + selected, + onClick, }: ViewProps) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; @@ -30,10 +34,14 @@ export default function ProgressCardView({ return ( { const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main; const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light; @@ -48,13 +56,19 @@ export default function ProgressCardView({ justifyContent: "center", position: "relative", overflow: "hidden", - border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none", - boxShadow: (theme) => - `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${ + border: selected + ? `2px solid #fff` + : isDark ? "1px solid rgba(255,255,255,0.1)" : "none", + boxShadow: (theme) => { + const baseShadow = `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${ isDark ? "rgba(0,0,0,0.5)" : theme.palette[colorTheme]?.main || theme.palette.primary.main - }`, + }`; + return selected + ? `${baseShadow}, 0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette[colorTheme]?.main || theme.palette.primary.main}` + : baseShadow; + }, }} > p.id === selectedPeriodId); + if (match) return match; + } + + // fallback → latest + return periods.reduce((latest, p) => + new Date(p.start).getTime() > new Date(latest.start).getTime() + ? p + : latest + ); +} + +// ─── Main adapter ──────────────────────────────────────────── + +export interface TagItem { + tag: string; + amount: number; +} + +export function extractTopTags( + reportData: ReportData, + mode: "expense" | "income", + selectedPeriodId?: string | null +): { items: TagItem[]; total: number } { + const tagMap = new Map(); + + for (const bucket of reportData.buckets) { + const tags = bucket.group_key.tags; + if (!tags || tags.length === 0) continue; + + // Prefer FULL if available + const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[]; + + const periodsToUse = selectedPeriodId + ? (Object.values(bucket.periods).flat() as DecoratedPeriod[]) + : fullPeriods; + + const period = findPeriod(periodsToUse, selectedPeriodId); + if (!period) continue; + + const amount = getAmount(period, mode); + + for (const tag of tags) { + tagMap.set(tag, (tagMap.get(tag) || 0) + amount); + } + } + + const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({ + tag, + amount, + })); + + arr.sort((a, b) => b.amount - a.amount); + + const top = arr.slice(0, 4); + const total = top.reduce((sum, t) => sum + t.amount, 0); + + return { items: top, total }; +} diff --git a/src/components/ProgressCard/TopTags.tsx b/src/components/ProgressCard/TopTags.tsx index 2de4cdf..48c1e4b 100644 --- a/src/components/ProgressCard/TopTags.tsx +++ b/src/components/ProgressCard/TopTags.tsx @@ -1,85 +1,28 @@ import * as React from "react"; import { Box } from "@mui/material"; -import { ReportData, ReportPeriod } from "../../features/report"; +import { ReportData, GroupKey } from "../../features/report"; import ProgressCard from "./ProgressCard"; +import { extractTopTags } from "./TopTags.adapter"; type Props = { reportData: ReportData; mode: "expense" | "income"; selectedPeriodId?: string | null; + selectedGroupKey?: GroupKey | null; + setSelectedGroupKey?: (key: GroupKey | null) => void; compact?: boolean; }; -type DecoratedPeriod = ReportPeriod & { - id: string; - label: string; -}; - -function getAmount(p: ReportPeriod, mode: "expense" | "income") { - return mode === "expense" ? p.expenses.sum : p.incomes.sum; -} - -function findPeriod( - periods: DecoratedPeriod[], - selectedPeriodId?: string | null -) { - if (!periods.length) return null; - - if (selectedPeriodId) { - const match = periods.find((p) => p.id === selectedPeriodId); - if (match) return match; - } - - // fallback → latest - return periods.reduce((latest, p) => - new Date(p.start).getTime() > new Date(latest.start).getTime() - ? p - : latest - ); -} - export default function TopTags({ reportData, mode, selectedPeriodId, - compact = true + selectedGroupKey, + setSelectedGroupKey, + compact = true, }: Props) { const { items, total } = React.useMemo(() => { - const tagMap = new Map(); - - for (const bucket of reportData.buckets) { - const tags = bucket.group_key.tags; - if (!tags || tags.length === 0) continue; - - // Prefer FULL if available - const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[]; - - const periodsToUse = - selectedPeriodId - ? Object.values(bucket.periods).flat() as DecoratedPeriod[] - : fullPeriods; - - const period = findPeriod(periodsToUse, selectedPeriodId); - if (!period) continue; - - const amount = getAmount(period, mode); - - for (const tag of tags) { - tagMap.set(tag, (tagMap.get(tag) || 0) + amount); - } - } - - const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({ - tag, - amount - })); - - arr.sort((a, b) => b.amount - a.amount); - - const top = arr.slice(0, 4); - const total = top.reduce((sum, t) => sum + t.amount, 0); - - return { items: top, total }; + return extractTopTags(reportData, mode, selectedPeriodId); }, [reportData, mode, selectedPeriodId]); return ( @@ -89,21 +32,30 @@ export default function TopTags({ gridTemplateColumns: { xs: "1fr", sm: "repeat(2, 1fr)", - md: "repeat(4, 1fr)" + md: "repeat(4, 1fr)", }, - gap: 2 + gap: 2, }} > - {items.map((item) => ( - - ))} + {items.map((item) => { + const isSelected = selectedGroupKey?.tags?.includes(item.tag); + return ( + { + if (setSelectedGroupKey) { + setSelectedGroupKey(isSelected ? null : { tags: [item.tag] }); + } + }} + /> + ); + })} ); } diff --git a/src/components/report.helpers.ts b/src/components/report.helpers.ts new file mode 100644 index 0000000..b77c3ac --- /dev/null +++ b/src/components/report.helpers.ts @@ -0,0 +1,147 @@ +import { + ReportPeriod, + ReportBucket, + GroupKey, +} from "../features/report"; + +// ─── Types ──────────────────────────────────────────────────── + +export type PeriodKey = "weekly" | "monthly" | "yearly" | "fyly" | "full"; + +export type DecoratedPeriod = ReportPeriod & { + id: string; + label: string; +}; + +// ─── Period helpers ─────────────────────────────────────────── + +const PREFIX_TO_KEY: Record = { + W: "weekly", + M: "monthly", + Y: "yearly", + FY: "fyly", + FULL: "full", +}; + +/** + * Derive the period key from a decorated-period id. + * E.g. `"W:2026-04-28_2026-05-04"` → `"weekly"` + */ +export function periodIdToKey(periodId: string): PeriodKey { + const prefix = periodId.split(":")[0]; + return PREFIX_TO_KEY[prefix] ?? "full"; +} + +// ─── Metric helpers ─────────────────────────────────────────── + +export function getAmount( + period: ReportPeriod, + mode: "expense" | "income" +): number { + return mode === "expense" ? period.expenses.sum : period.incomes.sum; +} + +function mergeMetric(a: ReportPeriod["expenses"], b: ReportPeriod["expenses"]) { + const sum = a.sum + b.sum; + const count = a.count + b.count; + + return { + ...a, + sum, + count, + average: count > 0 ? sum / count : 0, + transactions: + a.transactions || b.transactions + ? [...(a.transactions || []), ...(b.transactions || [])] + : undefined, + }; +} + +/** + * Merge periods with the same id across all buckets, summing + * their metrics and concatenating transactions. + * + * Returns sorted by start date ascending. + */ +export function mergeBucketPeriods( + buckets: ReportBucket[], + key: PeriodKey +): DecoratedPeriod[] { + const map = new Map(); + + for (const bucket of buckets) { + const periods = (bucket.periods[key] || []) as DecoratedPeriod[]; + + for (const p of periods) { + const existing = map.get(p.id); + + if (!existing) { + map.set(p.id, { + ...p, + expenses: { ...p.expenses }, + incomes: { ...p.incomes }, + }); + } else { + map.set(p.id, { + ...existing, + expenses: mergeMetric(existing.expenses, p.expenses), + incomes: mergeMetric(existing.incomes, p.incomes), + }); + } + } + } + + return Array.from(map.values()).sort( + (a, b) => new Date(a.start).getTime() - new Date(b.start).getTime() + ); +} + +// ─── Formatting ─────────────────────────────────────────────── + +export const formatCurrency = (val: number) => { + const absVal = Math.abs(val); + if (absVal >= 100000) { + return `₹ ${(val / 100000).toFixed(2)}L`; + } + if (absVal >= 1000) { + return `₹ ${(val / 1000).toFixed(2)}k`; + } + return `₹ ${val.toFixed(2)}`; +}; + +export const getPercentage = (progressAmount: number, totalAmount: number) => { + if (!totalAmount) return 0; + return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100)); +}; + +// ─── Group filtering ────────────────────────────────────────── + +/** + * Check if a bucket's group_key matches the selected GroupKey. + * Every dimension present in `selected` must exist in the bucket + * and contain all the selected values. + */ +export function matchesGroupKey( + bucket: ReportBucket, + selected: GroupKey +): boolean { + for (const [dim, values] of Object.entries(selected)) { + const bucketValues = bucket.group_key[dim as keyof GroupKey]; + if (!bucketValues) return false; + if (!(values as string[]).every((v) => bucketValues.includes(v))) + return false; + } + return true; +} + +/** + * Return only buckets matching the selected group key, + * or all buckets if no selection. + */ +export function filterBuckets( + buckets: ReportBucket[], + selectedGroupKey: GroupKey | null +): ReportBucket[] { + if (!selectedGroupKey) return buckets; + return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey)); +} diff --git a/src/dashboard-config.ts b/src/dashboard-config.ts index 5d7bab9..868716e 100644 --- a/src/dashboard-config.ts +++ b/src/dashboard-config.ts @@ -19,8 +19,8 @@ export const configuration: DashboardConfig = { }, }, { - id: "top-payees", - title: 'Top Payees', + id: "top-categories", + title: 'Top Categories', component: TopTags, settings: { compact: true, @@ -29,15 +29,13 @@ export const configuration: DashboardConfig = { size: 12, }, }, - // { - // id: "latest", - // title: 'Recent Transactions', - // component: LatestItems, - // dataKey: "latest", - // style: { - // size: 12, - // }, - // }, + { + id: "items", + component: LatestItems, + style: { + size: 12, + }, + }, ], style: { palette: { diff --git a/src/features/report/index.ts b/src/features/report/index.ts index 9092544..69e51e3 100644 --- a/src/features/report/index.ts +++ b/src/features/report/index.ts @@ -4,7 +4,9 @@ export { export type { Transaction, ReportData, + ReportBucket, ReportPeriod, + GroupKey, } from './report.models' export { prepareReport diff --git a/src/features/report/report.utils.ts b/src/features/report/report.utils.ts index b478dcf..10e4b9a 100644 --- a/src/features/report/report.utils.ts +++ b/src/features/report/report.utils.ts @@ -73,14 +73,11 @@ function buildLabel( end: Date ): string { switch (type) { - case "weekly": - if (sameMonth(start, end)) { - const sDay = start.getUTCDate(); - const eDay = end.getUTCDate(); - const m = monthFmt.format(start); - return `${sDay} ${m} - ${eDay} ${m}`; - } - return `${dayFmt.format(start)} - ${dayFmt.format(end)}`; + case "weekly": { + const sDay = start.getUTCDate(); + const m = monthFmt.format(start); + return `${sDay} ${m}`; + } case "monthly": return `${monthFmt.format(start)} ${yearFmt.format(start)}`;