top tag selection for further drill down

This commit is contained in:
2026-05-09 13:54:46 +05:30
parent f5322b8467
commit 17b5a107fe
11 changed files with 112 additions and 28 deletions

View File

@@ -1,6 +1,7 @@
import * as React from "react"; import * as React from "react";
import { import {
ReportData ReportData,
GroupKey,
} from "../../features/report"; } from "../../features/report";
export type DashboardMode = "expense" | "income"; export type DashboardMode = "expense" | "income";
@@ -11,6 +12,7 @@ export interface DashboardState {
mode: DashboardMode; mode: DashboardMode;
periodType: DashboardPeriodType; periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId; selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean; comparison: boolean;
} }

View File

@@ -7,6 +7,7 @@ export default function Dashboard(props: DashboardProps) {
mode: "expense", mode: "expense",
periodType: "rolling", periodType: "rolling",
selectedPeriodId: null, selectedPeriodId: null,
selectedGroupKey: null,
comparison: false, comparison: false,
}); });
@@ -35,6 +36,10 @@ export default function Dashboard(props: DashboardProps) {
setState(prev => ({ ...prev, selectedPeriodId })); setState(prev => ({ ...prev, selectedPeriodId }));
}; };
const setSelectedGroupKey = (groupKey: typeof state.selectedGroupKey) => {
setState(prev => ({ ...prev, selectedGroupKey: groupKey }));
};
return ( return (
<DashboardView <DashboardView
{...props} {...props}
@@ -44,6 +49,7 @@ export default function Dashboard(props: DashboardProps) {
togglePeriodType={togglePeriodType} togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison} toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId} setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
/> />
); );
} }

View File

@@ -8,6 +8,7 @@ import {
ToggleButtonGroup ToggleButtonGroup
} from "@mui/material"; } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { GroupKey } from "../../features/report";
import { DashboardProps, DashboardState } from "./Dashboard.models"; import { DashboardProps, DashboardState } from "./Dashboard.models";
interface ViewProps extends DashboardProps { interface ViewProps extends DashboardProps {
@@ -16,6 +17,7 @@ interface ViewProps extends DashboardProps {
toggleMode: () => void; toggleMode: () => void;
togglePeriodType: () => void; togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void; setSelectedPeriodId: (id: string | null) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleComparison: () => void; toggleComparison: () => void;
} }
@@ -28,10 +30,11 @@ export default function DashboardView({
togglePeriodType, togglePeriodType,
toggleComparison, toggleComparison,
setSelectedPeriodId, setSelectedPeriodId,
setSelectedGroupKey,
}: ViewProps) { }: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const themeMode = theme.palette.mode; const themeMode = theme.palette.mode;
const { mode, periodType, comparison, selectedPeriodId } = state; const { mode, periodType, comparison, selectedPeriodId, selectedGroupKey } = state;
// Resolve colors with fallbacks // Resolve colors with fallbacks
const colors = React.useMemo(() => { const colors = React.useMemo(() => {
@@ -120,10 +123,12 @@ export default function DashboardView({
periodType={periodType} periodType={periodType}
comparison={comparison} comparison={comparison}
selectedPeriodId={selectedPeriodId} selectedPeriodId={selectedPeriodId}
selectedGroupKey={selectedGroupKey}
togglePeriodType={togglePeriodType} togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison} toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId} setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
/> />
</Grid> </Grid>
); );

View File

@@ -1,8 +1,9 @@
import { ReportData, Transaction } from "../../features/report"; import { ReportData, Transaction, GroupKey } from "../../features/report";
import { import {
mergeBucketPeriods, mergeBucketPeriods,
periodIdToKey, periodIdToKey,
formatCurrency, formatCurrency,
filterBuckets,
} from "../report.helpers"; } from "../report.helpers";
import { LatestItem } from "./LatestItems.models"; import { LatestItem } from "./LatestItems.models";
@@ -11,11 +12,13 @@ import { LatestItem } from "./LatestItems.models";
function extractTransactions( function extractTransactions(
reportData: ReportData, reportData: ReportData,
selectedPeriodId: string | null, selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
mode: "expense" | "income" mode: "expense" | "income"
): Transaction[] { ): Transaction[] {
const buckets = filterBuckets(reportData.buckets, selectedGroupKey);
if (selectedPeriodId) { if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId); const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(reportData.buckets, key); const periods = mergeBucketPeriods(buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId); const selected = periods.find((p) => p.id === selectedPeriodId);
if (!selected) return []; if (!selected) return [];
@@ -25,7 +28,7 @@ function extractTransactions(
: (selected.incomes.transactions || []); : (selected.incomes.transactions || []);
} }
const periods = mergeBucketPeriods(reportData.buckets, "full"); const periods = mergeBucketPeriods(buckets, "full");
if (!periods.length) return []; if (!periods.length) return [];
@@ -41,9 +44,10 @@ function extractTransactions(
export function buildLatestItems( export function buildLatestItems(
reportData: ReportData, reportData: ReportData,
selectedPeriodId: string | null, selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
mode: "expense" | "income" mode: "expense" | "income"
): LatestItem[] { ): LatestItem[] {
const txns = extractTransactions(reportData, selectedPeriodId, mode); const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode);
return txns return txns
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0)) .filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))

View File

@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import { ReportData } from "../../features/report"; import { ReportData, GroupKey } from "../../features/report";
import { buildLatestItems } from "./LatestItems.adapter"; import { buildLatestItems } from "./LatestItems.adapter";
import LatestItemsView from "./LatestItems.view"; import LatestItemsView from "./LatestItems.view";
@@ -7,6 +7,7 @@ type Props = {
reportData: ReportData; reportData: ReportData;
mode: "expense" | "income"; mode: "expense" | "income";
selectedPeriodId: string | null; selectedPeriodId: string | null;
selectedGroupKey?: GroupKey | null;
accentColor: string; accentColor: string;
}; };
@@ -14,22 +15,23 @@ export default function LatestItems({
reportData, reportData,
mode, mode,
selectedPeriodId, selectedPeriodId,
selectedGroupKey = null,
accentColor, accentColor,
}: Props) { }: Props) {
const [visibleCount, setVisibleCount] = React.useState(5); const [visibleCount, setVisibleCount] = React.useState(5);
const allItems = React.useMemo(() => { const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, mode); return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, mode);
}, [reportData, selectedPeriodId, mode]); }, [reportData, selectedPeriodId, selectedGroupKey, mode]);
const isPeriodSelected = Boolean(selectedPeriodId); const hasSelection = Boolean(selectedPeriodId) || Boolean(selectedGroupKey);
const visibleItems = React.useMemo(() => { const visibleItems = React.useMemo(() => {
if (!isPeriodSelected) return allItems.slice(0, 5); if (!hasSelection) return allItems.slice(0, 5);
return allItems.slice(0, visibleCount); return allItems.slice(0, visibleCount);
}, [allItems, isPeriodSelected, visibleCount]); }, [allItems, hasSelection, visibleCount]);
const canExpand = isPeriodSelected && visibleCount < allItems.length; const canExpand = hasSelection && visibleCount < allItems.length;
return ( return (
<LatestItemsView <LatestItemsView

View File

@@ -5,4 +5,6 @@ export interface ProgressCardProps {
totalAmount: number; totalAmount: number;
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning"; colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
compact?: boolean; compact?: boolean;
selected?: boolean;
onClick?: () => void;
} }

View File

@@ -18,6 +18,8 @@ export default function ProgressCard(props: ProgressCardProps) {
formattedProgress={formattedProgress} formattedProgress={formattedProgress}
formattedTotal={formattedTotal} formattedTotal={formattedTotal}
compact={compact} compact={compact}
selected={props.selected}
onClick={props.onClick}
/> />
); );
} }

View File

@@ -14,6 +14,8 @@ interface ViewProps extends ProgressCardProps {
percentage: number; percentage: number;
formattedProgress: string; formattedProgress: string;
formattedTotal: string; formattedTotal: string;
selected?: boolean;
onClick?: () => void;
} }
export default function ProgressCardView({ export default function ProgressCardView({
@@ -23,6 +25,8 @@ export default function ProgressCardView({
formattedProgress, formattedProgress,
formattedTotal, formattedTotal,
compact = false, compact = false,
selected,
onClick,
}: ViewProps) { }: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === "dark";
@@ -30,10 +34,14 @@ export default function ProgressCardView({
return ( return (
<Paper <Paper
elevation={compact ? 2 : 4} elevation={compact ? 2 : 4}
onClick={onClick}
sx={{ sx={{
width: "100%", width: "100%",
p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 }, p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
borderRadius: compact ? 3 : 4, borderRadius: compact ? 3 : 4,
cursor: onClick ? "pointer" : "default",
transform: selected ? "scale(1.02)" : "scale(1)",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
background: (theme) => { background: (theme) => {
const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main; const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main;
const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light; const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light;
@@ -48,13 +56,19 @@ export default function ProgressCardView({
justifyContent: "center", justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none", border: selected
boxShadow: (theme) => ? `2px solid #fff`
`0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${ : 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 isDark
? "rgba(0,0,0,0.5)" ? "rgba(0,0,0,0.5)"
: theme.palette[colorTheme]?.main || theme.palette.primary.main : 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;
},
}} }}
> >
<Typography <Typography

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { ReportData } from "../../features/report"; import { ReportData, GroupKey } from "../../features/report";
import ProgressCard from "./ProgressCard"; import ProgressCard from "./ProgressCard";
import { extractTopTags } from "./TopTags.adapter"; import { extractTopTags } from "./TopTags.adapter";
@@ -8,6 +8,8 @@ type Props = {
reportData: ReportData; reportData: ReportData;
mode: "expense" | "income"; mode: "expense" | "income";
selectedPeriodId?: string | null; selectedPeriodId?: string | null;
selectedGroupKey?: GroupKey | null;
setSelectedGroupKey?: (key: GroupKey | null) => void;
compact?: boolean; compact?: boolean;
}; };
@@ -15,6 +17,8 @@ export default function TopTags({
reportData, reportData,
mode, mode,
selectedPeriodId, selectedPeriodId,
selectedGroupKey,
setSelectedGroupKey,
compact = true, compact = true,
}: Props) { }: Props) {
const { items, total } = React.useMemo(() => { const { items, total } = React.useMemo(() => {
@@ -33,16 +37,25 @@ export default function TopTags({
gap: 2, gap: 2,
}} }}
> >
{items.map((item) => ( {items.map((item) => {
<ProgressCard const isSelected = selectedGroupKey?.tags?.includes(item.tag);
key={item.tag} return (
header={item.tag} <ProgressCard
progressAmount={item.amount} key={item.tag}
totalAmount={total} header={item.tag}
compact={compact} progressAmount={item.amount}
colorTheme={mode === "expense" ? "error" : "success"} totalAmount={total}
/> compact={compact}
))} colorTheme={mode === "expense" ? "error" : "success"}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
setSelectedGroupKey(isSelected ? null : { tags: [item.tag] });
}
}}
/>
);
})}
</Box> </Box>
); );
} }

View File

@@ -1,6 +1,7 @@
import { import {
ReportPeriod, ReportPeriod,
ReportBucket, ReportBucket,
GroupKey,
} from "../features/report"; } from "../features/report";
// ─── Types ──────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────
@@ -112,3 +113,35 @@ export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0; if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100)); 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));
}

View File

@@ -6,6 +6,7 @@ export type {
ReportData, ReportData,
ReportBucket, ReportBucket,
ReportPeriod, ReportPeriod,
GroupKey,
} from './report.models' } from './report.models'
export { export {
prepareReport prepareReport