From 052c5a3026e5c9cca48908caedab596486511b09 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 7 May 2026 17:29:09 +0530 Subject: [PATCH 1/7] enabled latest items --- src/components/LatestItems/LatestItems.tsx | 182 ++++++++++++++++----- src/dashboard-config.ts | 16 +- 2 files changed, 146 insertions(+), 52 deletions(-) diff --git a/src/components/LatestItems/LatestItems.tsx b/src/components/LatestItems/LatestItems.tsx index 9c9f247..9d0aef3 100644 --- a/src/components/LatestItems/LatestItems.tsx +++ b/src/components/LatestItems/LatestItems.tsx @@ -1,3 +1,5 @@ +// components/LatestItems/LatestItems.tsx + import * as React from "react"; import { List, @@ -7,64 +9,161 @@ import { Avatar, Typography, Box, - Button, + IconButton } from "@mui/material"; +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import ExpandLessIcon from "@mui/icons-material/ExpandLess"; -export interface LatestItem { - id: string | number; - icon: React.ReactNode; - iconBgColor?: string; - title: string; - subtitle: string; - amount: string; - timeAgo: string; +import { ReportData, Transaction, ReportPeriod } from "../../features/report"; +import { formatCurrency } from "../ProgressCard/ProgressCard.utils"; + +type Props = { + reportData: ReportData; + mode: "expense" | "income"; + selectedPeriodId: string | null; + accentColor: string; +}; + +type DecoratedPeriod = ReportPeriod & { + id: string; + label: string; +}; + +function mergePeriods( + reportData: ReportData, + key: "weekly" | "monthly" | "yearly" | "fyly" | "full" +): DecoratedPeriod[] { + const map = new Map(); + + for (const bucket of reportData.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, + transactions: [...(p.expenses.transactions || [])], + }, + incomes: { + ...p.incomes, + transactions: [...(p.incomes.transactions || [])], + }, + }); + } else { + existing.expenses.transactions?.push(...(p.expenses.transactions || [])); + existing.incomes.transactions?.push(...(p.incomes.transactions || [])); + } + } + } + + return Array.from(map.values()); } -export interface LatestItemsListProps { - title?: string; - items: LatestItem[]; - onViewAll?: () => void; - accentColor: any; +function extractTransactions( + reportData: ReportData, + selectedPeriodId: string | null, + mode: "expense" | "income", +): Transaction[] { + let periods: DecoratedPeriod[] = []; + + if (selectedPeriodId) { + const prefix = selectedPeriodId.split(":")[0]; + + const map: any = { + W: "weekly", + M: "monthly", + Y: "yearly", + FY: "fyly", + FULL: "full" + }; + + const key = map[prefix]; + + periods = mergePeriods(reportData, key); + const selected = periods.find(p => p.id === selectedPeriodId); + + if (!selected) return []; + + return mode === "expense" + ? (selected.expenses.transactions || []) + : (selected.incomes.transactions || []); + } + + // default → FULL + periods = mergePeriods(reportData, "full"); + + if (!periods.length) return []; + + const full = periods[0]; + + return mode === "expense" + ? (full.expenses.transactions || []) + : (full.incomes.transactions || []); } export default function LatestItems({ - title = "Recent Transactions", - items, - onViewAll, - accentColor, -}: LatestItemsListProps) { + reportData, + mode, + selectedPeriodId, + accentColor +}: Props) { + const [expanded, setExpanded] = React.useState(false); + + const items = React.useMemo(() => { + const txns = extractTransactions(reportData, selectedPeriodId, 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"), + })); + }, [reportData, selectedPeriodId, mode]); + + const isPeriodSelected = Boolean(selectedPeriodId); + + const visibleItems = React.useMemo(() => { + if (!isPeriodSelected) return items.slice(0, 5); + if (expanded) return items; + return items.slice(0, 5); + }, [items, isPeriodSelected, expanded]); + return ( - {/* Header */} - {title} + Recent Transactions - {onViewAll && ( - + + {isPeriodSelected && items.length > 5 && ( + setExpanded((p) => !p)}> + {expanded ? : } + )} - {/* List */} - {items.map((item, index) => ( + {visibleItems.map((item, index) => ( @@ -72,20 +171,17 @@ export default function LatestItems({ variant="rounded" sx={{ bgcolor: `${accentColor}22`, - color: "inherit", width: 48, height: 48, borderRadius: 3, mr: 2, }} - > - {item.icon} - + /> - + + {item.title} } @@ -97,10 +193,10 @@ export default function LatestItems({ /> - + {item.amount} - + {item.timeAgo} diff --git a/src/dashboard-config.ts b/src/dashboard-config.ts index 5d7bab9..26b44ec 100644 --- a/src/dashboard-config.ts +++ b/src/dashboard-config.ts @@ -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: { -- 2.49.1 From f025a7d9bf3fae619e50ae3d802c245be0e8da3f Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 7 May 2026 17:32:16 +0530 Subject: [PATCH 2/7] expand fixes --- src/components/LatestItems/LatestItems.tsx | 32 ++++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/src/components/LatestItems/LatestItems.tsx b/src/components/LatestItems/LatestItems.tsx index 9d0aef3..1439727 100644 --- a/src/components/LatestItems/LatestItems.tsx +++ b/src/components/LatestItems/LatestItems.tsx @@ -1,5 +1,3 @@ -// components/LatestItems/LatestItems.tsx - import * as React from "react"; import { List, @@ -12,7 +10,6 @@ import { IconButton } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import ExpandLessIcon from "@mui/icons-material/ExpandLess"; import { ReportData, Transaction, ReportPeriod } from "../../features/report"; import { formatCurrency } from "../ProgressCard/ProgressCard.utils"; @@ -93,7 +90,6 @@ function extractTransactions( : (selected.incomes.transactions || []); } - // default → FULL periods = mergePeriods(reportData, "full"); if (!periods.length) return []; @@ -111,7 +107,7 @@ export default function LatestItems({ selectedPeriodId, accentColor }: Props) { - const [expanded, setExpanded] = React.useState(false); + const [visibleCount, setVisibleCount] = React.useState(5); const items = React.useMemo(() => { const txns = extractTransactions(reportData, selectedPeriodId, mode); @@ -136,22 +132,17 @@ export default function LatestItems({ const visibleItems = React.useMemo(() => { if (!isPeriodSelected) return items.slice(0, 5); - if (expanded) return items; - return items.slice(0, 5); - }, [items, isPeriodSelected, expanded]); + return items.slice(0, visibleCount); + }, [items, isPeriodSelected, visibleCount]); + + const canExpand = isPeriodSelected && visibleCount < items.length; return ( - + Recent Transactions - - {isPeriodSelected && items.length > 5 && ( - setExpanded((p) => !p)}> - {expanded ? : } - - )} @@ -202,6 +193,17 @@ export default function LatestItems({ ))} + + {canExpand && ( + + setVisibleCount((prev) => prev + 5)} + > + + + + )} ); -- 2.49.1 From 4c8552051cefafc4cf2ebe07bc7f7398ccb75550 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Thu, 7 May 2026 19:45:53 +0530 Subject: [PATCH 3/7] weekly label fix --- src/features/report/report.utils.ts | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) 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)}`; -- 2.49.1 From 1423f889ba245033f3b0c0095918013a2a7454c7 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 9 May 2026 13:29:49 +0530 Subject: [PATCH 4/7] cleanup --- src/components/HistoryChart/HistoryChart.tsx | 128 +---------- .../LatestItems/LatestItems.models.ts | 10 +- src/components/LatestItems/LatestItems.tsx | 200 ++---------------- .../LatestItems/LatestItems.view.tsx | 90 +++++++- src/components/ProgressCard/ProgressCard.tsx | 2 +- .../ProgressCard/ProgressCard.utils.ts | 15 -- src/components/ProgressCard/TopTags.tsx | 73 +------ src/features/report/index.ts | 1 + 8 files changed, 117 insertions(+), 402 deletions(-) delete mode 100644 src/components/ProgressCard/ProgressCard.utils.ts 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.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 1439727..882c0f9 100644 --- a/src/components/LatestItems/LatestItems.tsx +++ b/src/components/LatestItems/LatestItems.tsx @@ -1,18 +1,7 @@ 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 { ReportData, Transaction, ReportPeriod } from "../../features/report"; -import { formatCurrency } from "../ProgressCard/ProgressCard.utils"; +import { ReportData } from "../../features/report"; +import { buildLatestItems } from "./LatestItems.adapter"; +import LatestItemsView from "./LatestItems.view"; type Props = { reportData: ReportData; @@ -21,190 +10,33 @@ type Props = { accentColor: string; }; -type DecoratedPeriod = ReportPeriod & { - id: string; - label: string; -}; - -function mergePeriods( - reportData: ReportData, - key: "weekly" | "monthly" | "yearly" | "fyly" | "full" -): DecoratedPeriod[] { - const map = new Map(); - - for (const bucket of reportData.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, - transactions: [...(p.expenses.transactions || [])], - }, - incomes: { - ...p.incomes, - transactions: [...(p.incomes.transactions || [])], - }, - }); - } else { - existing.expenses.transactions?.push(...(p.expenses.transactions || [])); - existing.incomes.transactions?.push(...(p.incomes.transactions || [])); - } - } - } - - return Array.from(map.values()); -} - -function extractTransactions( - reportData: ReportData, - selectedPeriodId: string | null, - mode: "expense" | "income", -): Transaction[] { - let periods: DecoratedPeriod[] = []; - - if (selectedPeriodId) { - const prefix = selectedPeriodId.split(":")[0]; - - const map: any = { - W: "weekly", - M: "monthly", - Y: "yearly", - FY: "fyly", - FULL: "full" - }; - - const key = map[prefix]; - - periods = mergePeriods(reportData, key); - const selected = periods.find(p => p.id === selectedPeriodId); - - if (!selected) return []; - - return mode === "expense" - ? (selected.expenses.transactions || []) - : (selected.incomes.transactions || []); - } - - periods = mergePeriods(reportData, "full"); - - if (!periods.length) return []; - - const full = periods[0]; - - return mode === "expense" - ? (full.expenses.transactions || []) - : (full.incomes.transactions || []); -} - export default function LatestItems({ reportData, mode, selectedPeriodId, - accentColor + accentColor, }: Props) { const [visibleCount, setVisibleCount] = React.useState(5); - const items = React.useMemo(() => { - const txns = extractTransactions(reportData, selectedPeriodId, 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"), - })); + const allItems = React.useMemo(() => { + return buildLatestItems(reportData, selectedPeriodId, mode); }, [reportData, selectedPeriodId, mode]); const isPeriodSelected = Boolean(selectedPeriodId); const visibleItems = React.useMemo(() => { - if (!isPeriodSelected) return items.slice(0, 5); - return items.slice(0, visibleCount); - }, [items, isPeriodSelected, visibleCount]); + if (!isPeriodSelected) return allItems.slice(0, 5); + return allItems.slice(0, visibleCount); + }, [allItems, isPeriodSelected, visibleCount]); - const canExpand = isPeriodSelected && visibleCount < items.length; + const canExpand = isPeriodSelected && visibleCount < allItems.length; return ( - - - - Recent Transactions - - - - - {visibleItems.map((item, index) => ( - - - - - - - {item.title} - - } - secondary={ - - {item.subtitle} - - } - /> - - - - {item.amount} - - - {item.timeAgo} - - - - ))} - - {canExpand && ( - - setVisibleCount((prev) => prev + 5)} - > - - - - )} - - + 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.tsx b/src/components/ProgressCard/ProgressCard.tsx index 3441a2f..d31cd36 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; 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/TopTags.tsx b/src/components/ProgressCard/TopTags.tsx index 2de4cdf..5e8860a 100644 --- a/src/components/ProgressCard/TopTags.tsx +++ b/src/components/ProgressCard/TopTags.tsx @@ -1,7 +1,8 @@ import * as React from "react"; import { Box } from "@mui/material"; -import { ReportData, ReportPeriod } from "../../features/report"; +import { ReportData } from "../../features/report"; import ProgressCard from "./ProgressCard"; +import { extractTopTags } from "./TopTags.adapter"; type Props = { reportData: ReportData; @@ -10,76 +11,14 @@ type Props = { 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 + 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,9 +28,9 @@ 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) => ( diff --git a/src/features/report/index.ts b/src/features/report/index.ts index 9092544..ceb4378 100644 --- a/src/features/report/index.ts +++ b/src/features/report/index.ts @@ -4,6 +4,7 @@ export { export type { Transaction, ReportData, + ReportBucket, ReportPeriod, } from './report.models' export { -- 2.49.1 From f5322b84678c2af90bbc5c39bacaac473ab00999 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 9 May 2026 13:30:01 +0530 Subject: [PATCH 5/7] cleanup --- .../HistoryChart/HistoryChart.adapter.ts | 75 ++++++++++++ .../LatestItems/LatestItems.adapter.ts | 62 ++++++++++ .../ProgressCard/TopTags.adapter.ts | 74 ++++++++++++ src/components/report.helpers.ts | 114 ++++++++++++++++++ 4 files changed, 325 insertions(+) create mode 100644 src/components/HistoryChart/HistoryChart.adapter.ts create mode 100644 src/components/LatestItems/LatestItems.adapter.ts create mode 100644 src/components/ProgressCard/TopTags.adapter.ts create mode 100644 src/components/report.helpers.ts 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/LatestItems/LatestItems.adapter.ts b/src/components/LatestItems/LatestItems.adapter.ts new file mode 100644 index 0000000..df36795 --- /dev/null +++ b/src/components/LatestItems/LatestItems.adapter.ts @@ -0,0 +1,62 @@ +import { ReportData, Transaction } from "../../features/report"; +import { + mergeBucketPeriods, + periodIdToKey, + formatCurrency, +} from "../report.helpers"; +import { LatestItem } from "./LatestItems.models"; + +// ─── Transaction extraction ───────────────────────────────── + +function extractTransactions( + reportData: ReportData, + selectedPeriodId: string | null, + mode: "expense" | "income" +): Transaction[] { + if (selectedPeriodId) { + const key = periodIdToKey(selectedPeriodId); + const periods = mergeBucketPeriods(reportData.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(reportData.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, + mode: "expense" | "income" +): LatestItem[] { + const txns = extractTransactions(reportData, selectedPeriodId, 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/ProgressCard/TopTags.adapter.ts b/src/components/ProgressCard/TopTags.adapter.ts new file mode 100644 index 0000000..ea0eb63 --- /dev/null +++ b/src/components/ProgressCard/TopTags.adapter.ts @@ -0,0 +1,74 @@ +import { ReportData } from "../../features/report"; +import { + getAmount, + DecoratedPeriod, +} from "../report.helpers"; + +// ─── Helpers ───────────────────────────────────────────────── + +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 + ); +} + +// ─── 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/report.helpers.ts b/src/components/report.helpers.ts new file mode 100644 index 0000000..eb003fd --- /dev/null +++ b/src/components/report.helpers.ts @@ -0,0 +1,114 @@ +import { + ReportPeriod, + ReportBucket, +} 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)); +}; -- 2.49.1 From 17b5a107fe6ee9599ab9f7fa28dca5845131a10e Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 9 May 2026 13:54:46 +0530 Subject: [PATCH 6/7] top tag selection for further drill down --- src/components/Dashboard/Dashboard.models.ts | 4 ++- src/components/Dashboard/Dashboard.tsx | 6 ++++ src/components/Dashboard/Dashboard.view.tsx | 7 +++- .../LatestItems/LatestItems.adapter.ts | 12 ++++--- src/components/LatestItems/LatestItems.tsx | 16 +++++---- .../ProgressCard/ProgressCard.models.ts | 2 ++ src/components/ProgressCard/ProgressCard.tsx | 2 ++ .../ProgressCard/ProgressCard.view.tsx | 22 +++++++++--- src/components/ProgressCard/TopTags.tsx | 35 +++++++++++++------ src/components/report.helpers.ts | 33 +++++++++++++++++ src/features/report/index.ts | 1 + 11 files changed, 112 insertions(+), 28 deletions(-) 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/LatestItems/LatestItems.adapter.ts b/src/components/LatestItems/LatestItems.adapter.ts index df36795..d8236ee 100644 --- a/src/components/LatestItems/LatestItems.adapter.ts +++ b/src/components/LatestItems/LatestItems.adapter.ts @@ -1,8 +1,9 @@ -import { ReportData, Transaction } from "../../features/report"; +import { ReportData, Transaction, GroupKey } from "../../features/report"; import { mergeBucketPeriods, periodIdToKey, formatCurrency, + filterBuckets, } from "../report.helpers"; import { LatestItem } from "./LatestItems.models"; @@ -11,11 +12,13 @@ import { LatestItem } from "./LatestItems.models"; 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(reportData.buckets, key); + const periods = mergeBucketPeriods(buckets, key); const selected = periods.find((p) => p.id === selectedPeriodId); if (!selected) return []; @@ -25,7 +28,7 @@ function extractTransactions( : (selected.incomes.transactions || []); } - const periods = mergeBucketPeriods(reportData.buckets, "full"); + const periods = mergeBucketPeriods(buckets, "full"); if (!periods.length) return []; @@ -41,9 +44,10 @@ function extractTransactions( export function buildLatestItems( reportData: ReportData, selectedPeriodId: string | null, + selectedGroupKey: GroupKey | null, mode: "expense" | "income" ): LatestItem[] { - const txns = extractTransactions(reportData, selectedPeriodId, mode); + const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode); return txns .filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0)) diff --git a/src/components/LatestItems/LatestItems.tsx b/src/components/LatestItems/LatestItems.tsx index 882c0f9..99c4712 100644 --- a/src/components/LatestItems/LatestItems.tsx +++ b/src/components/LatestItems/LatestItems.tsx @@ -1,5 +1,5 @@ import * as React from "react"; -import { ReportData } from "../../features/report"; +import { ReportData, GroupKey } from "../../features/report"; import { buildLatestItems } from "./LatestItems.adapter"; import LatestItemsView from "./LatestItems.view"; @@ -7,6 +7,7 @@ type Props = { reportData: ReportData; mode: "expense" | "income"; selectedPeriodId: string | null; + selectedGroupKey?: GroupKey | null; accentColor: string; }; @@ -14,22 +15,23 @@ export default function LatestItems({ reportData, mode, selectedPeriodId, + selectedGroupKey = null, accentColor, }: Props) { const [visibleCount, setVisibleCount] = React.useState(5); const allItems = React.useMemo(() => { - return buildLatestItems(reportData, selectedPeriodId, mode); - }, [reportData, selectedPeriodId, mode]); + return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, mode); + }, [reportData, selectedPeriodId, selectedGroupKey, mode]); - const isPeriodSelected = Boolean(selectedPeriodId); + const hasSelection = Boolean(selectedPeriodId) || Boolean(selectedGroupKey); const visibleItems = React.useMemo(() => { - if (!isPeriodSelected) return allItems.slice(0, 5); + if (!hasSelection) return allItems.slice(0, 5); return allItems.slice(0, visibleCount); - }, [allItems, isPeriodSelected, visibleCount]); + }, [allItems, hasSelection, visibleCount]); - const canExpand = isPeriodSelected && visibleCount < allItems.length; + const canExpand = hasSelection && visibleCount < allItems.length; return ( void; } diff --git a/src/components/ProgressCard/ProgressCard.tsx b/src/components/ProgressCard/ProgressCard.tsx index d31cd36..b42c2e4 100644 --- a/src/components/ProgressCard/ProgressCard.tsx +++ b/src/components/ProgressCard/ProgressCard.tsx @@ -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.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; + }, }} > void; compact?: boolean; }; @@ -15,6 +17,8 @@ export default function TopTags({ reportData, mode, selectedPeriodId, + selectedGroupKey, + setSelectedGroupKey, compact = true, }: Props) { const { items, total } = React.useMemo(() => { @@ -33,16 +37,25 @@ export default function TopTags({ 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 index eb003fd..b77c3ac 100644 --- a/src/components/report.helpers.ts +++ b/src/components/report.helpers.ts @@ -1,6 +1,7 @@ import { ReportPeriod, ReportBucket, + GroupKey, } from "../features/report"; // ─── Types ──────────────────────────────────────────────────── @@ -112,3 +113,35 @@ 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/features/report/index.ts b/src/features/report/index.ts index ceb4378..69e51e3 100644 --- a/src/features/report/index.ts +++ b/src/features/report/index.ts @@ -6,6 +6,7 @@ export type { ReportData, ReportBucket, ReportPeriod, + GroupKey, } from './report.models' export { prepareReport -- 2.49.1 From 39f3d87d21e974631377bb1a6f84efcc2a1f4ab3 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Sat, 9 May 2026 13:54:52 +0530 Subject: [PATCH 7/7] cleanup --- src/dashboard-config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/dashboard-config.ts b/src/dashboard-config.ts index 26b44ec..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, -- 2.49.1