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