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)}`;