From 16d164b92a9bd0efb573037b5d5301247f44ac23 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Tue, 19 May 2026 07:11:46 +0000 Subject: [PATCH] Dashboard Refactor (#5) # Dashboard Refactor ## Overview This merge request performs a major cleanup and architectural refactor of the dashboard component system. The primary goals were: * Consolidate dashboard state handling * Standardize component contracts * Remove duplicated transaction aggregation logic * Simplify theming * Eliminate unnecessary wrapper/view layers * Improve maintainability and type safety --- # Major Changes ## Dashboard Architecture Refactor ### Consolidated State API Introduced a centralized `DashboardStateSetters` interface: ```ts export interface DashboardStateSetters { setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void; setSelectedGroupKey: (groupKey: GroupKey | null) => void; toggleFlow: () => void; togglePeriodType: () => void; toggleComparison: () => void; } ``` This removes scattered prop drilling and standardizes dashboard interaction handling. --- ### Introduced Shared `ComponentProps` All dashboard widgets now consume a unified contract: ```ts export interface ComponentProps extends DashboardSection { reportData: ReportData; state: DashboardState; stateSetters: DashboardStateSetters; isFetching: boolean; colorScheme: { primary: string; light: string; text: string; }; } ``` Benefits: * Consistent widget APIs * Reduced repetitive prop definitions * Easier extensibility * Cleaner component composition --- ### Removed `Dashboard.view.tsx` The view/container split was removed. Dashboard rendering now lives directly inside: ```ts Dashboard.tsx ``` Benefits: * Reduced indirection * Easier navigation * Lower cognitive overhead * Simpler state flow --- ## Dashboard Config Cleanup Removed legacy `style.size` configuration from dashboard sections. Before: ```ts style: { size: 12, } ``` Now: ```ts { id: "items", title: "Recent Transactions", component: LatestItems, } ``` This simplifies section configuration and removes unnecessary abstraction. --- # Shared Transaction Utilities ## Added `extractFilteredTransactions` Created a reusable transaction extraction helper: ```ts extractFilteredTransactions( reportData, selectedPeriodId, selectedGroupKey ) ``` This centralizes: * Period resolution * Group filtering * Tag filtering * Payee filtering Previously duplicated across: * LatestItems * TopTags * TopPayees --- ## Added `aggregateTransactions` Created a reusable aggregation utility: ```ts aggregateTransactions( transactions, keyExtractor, limit ) ``` Benefits: * Removes repeated Map aggregation logic * Standardizes sorting and totals * Simplifies adapters significantly --- # HistoryChart Refactor ## Split Models vs Props Separated: * data models * component props * view props into dedicated files. New: ```txt HistoryChart.models.ts HistoryChart.props.ts ``` Benefits: * Cleaner typing boundaries * Better maintainability * Reduced coupling --- ## Migrated to Shared Dashboard State HistoryChart now consumes: ```ts state stateSetters ``` instead of individual props. This aligns it with the new dashboard architecture. --- # LatestItems Refactor ## Simplified Component Contract Removed duplicated props: * flow * header * accentColor * selectedPeriodId * selectedGroupKey Now inherited from shared `ComponentProps`. --- ## Added Auto Reset on Flow Change ```ts React.useEffect(() => { setVisibleCount(5); }, [flow]); ``` Improves UX when switching inflow/outflow views. --- # ProgressCard Refactor ## Removed `ProgressCard.tsx` Deleted unnecessary wrapper component. Rendering logic now lives directly in: ```txt ProgressCard.view.tsx ``` --- ## Introduced `ProgressCard.props.ts` Separated props into dedicated interfaces: ```ts ProgressCardProps ProgressCardViewProps ``` --- ## Reworked Styling System Removed dependency on: ```ts colorTheme ``` Now fully driven by: ```ts colorScheme ``` Benefits: * Consistent dashboard-wide theming * Better dark mode behavior * Reduced MUI palette coupling --- ## Improved Visual Consistency Updated: * borders * shadows * progress bar styling * dark mode surfaces * selection state styling to use standardized dashboard colors. --- # TopTags & TopPayees Refactor ## Removed Duplicated Aggregation Logic Both adapters now use: ```ts extractFilteredTransactions() aggregateTransactions() ``` instead of maintaining separate filtering/aggregation implementations. Benefits: * Less code duplication * Consistent behavior * Easier future maintenance --- ## Migrated to Shared Component Props Both components now consume: ```ts ComponentProps ``` via: ```ts ProgressCardProps ``` This aligns all dashboard widgets under the same architecture. --- # Theme System Cleanup ## Consolidated AppTheme Moved to: ```txt src/shared-theme/AppTheme.tsx ``` and removed unused duplicate implementations. --- ## Added Explicit Color Mode Context Introduced: ```ts ColorModeContext ``` with: * `mode` * `setMode` * `toggleColorMode` This provides a cleaner foundation for future theme controls. --- ## Simplified Theme Creation Replaced older MUI experimental color scheme setup with: ```ts getDesignTokens(mode) ``` Benefits: * Easier to reason about * Cleaner light/dark handling * Less framework complexity --- ## Added Global CssBaseline ```tsx ``` is now applied centrally inside `AppTheme`. --- # Type Safety Improvements ## Removed Optional Fields Where Invalid Several previously optional fields are now required: ```ts title: string background: string text: string isFetching: boolean style.palette ``` Benefits: * Stronger guarantees * Fewer runtime fallbacks * Simpler rendering logic --- # Cleanup Summary ## Removed * `Dashboard.view.tsx` * `ProgressCard.tsx` * legacy prop duplication * repeated transaction extraction logic * repeated aggregation logic * unused style configuration * legacy theme configuration complexity --- ## Added * centralized dashboard state setters * reusable transaction helpers * reusable aggregation helper * unified component props * dedicated prop definition files * explicit color mode context * consolidated theme provider --- # Result The dashboard system is now: * significantly more maintainable * easier to extend * more type-safe * less repetitive * more consistent across widgets * cleaner in terms of state ownership * simpler to theme and customize Reviewed-on: https://git.aetoskia.com/apps/khata-ui/pulls/5 Co-authored-by: Vishesh 'ironeagle' Bangotra Co-committed-by: Vishesh 'ironeagle' Bangotra --- src/components/Dashboard/Dashboard.models.ts | 42 ++-- src/components/Dashboard/Dashboard.tsx | 188 +++++++++++++++--- src/components/Dashboard/Dashboard.view.tsx | 143 ------------- .../HistoryChart/HistoryChart.models.ts | 32 --- .../HistoryChart/HistoryChart.props.ts | 21 ++ src/components/HistoryChart/HistoryChart.tsx | 17 +- .../HistoryChart/HistoryChart.view.tsx | 67 +++---- .../LatestItems/LatestItems.adapter.ts | 58 +----- .../LatestItems/LatestItems.models.ts | 9 - .../LatestItems/LatestItems.props.ts | 10 + src/components/LatestItems/LatestItems.tsx | 37 ++-- .../LatestItems/LatestItems.view.tsx | 10 +- .../ProgressCard/ProgressCard.models.ts | 11 - .../ProgressCard/ProgressCard.props.ts | 14 ++ src/components/ProgressCard/ProgressCard.tsx | 25 --- .../ProgressCard/ProgressCard.view.tsx | 124 ++++++------ .../ProgressCard/TopPayees.adapter.ts | 56 +----- src/components/ProgressCard/TopPayees.tsx | 48 ++--- .../ProgressCard/TopTags.adapter.ts | 66 ++---- src/components/ProgressCard/TopTags.tsx | 48 ++--- src/components/ProgressCard/index.ts | 4 +- src/components/report.helpers.ts | 88 ++++++++ src/dashboard-config.ts | 12 -- 23 files changed, 510 insertions(+), 620 deletions(-) delete mode 100644 src/components/Dashboard/Dashboard.view.tsx create mode 100644 src/components/HistoryChart/HistoryChart.props.ts create mode 100644 src/components/LatestItems/LatestItems.props.ts delete mode 100644 src/components/ProgressCard/ProgressCard.models.ts create mode 100644 src/components/ProgressCard/ProgressCard.props.ts delete mode 100644 src/components/ProgressCard/ProgressCard.tsx diff --git a/src/components/Dashboard/Dashboard.models.ts b/src/components/Dashboard/Dashboard.models.ts index e22879e..5ec4cf3 100644 --- a/src/components/Dashboard/Dashboard.models.ts +++ b/src/components/Dashboard/Dashboard.models.ts @@ -16,23 +16,26 @@ export interface DashboardState { comparison: boolean; } +export interface DashboardStateSetters { + setSelectedPeriodId: (id: DashboardSelectedPeriodId) => void; + setSelectedGroupKey: (groupKey: GroupKey | null) => void; + toggleFlow: () => void; + togglePeriodType: () => void; + toggleComparison: () => void; +} + export interface DashboardSection { id: string; - title?: string; - summary?: string; + title: string; component: React.ComponentType; + summary?: string; settings?: Record; - isList?: boolean; - style?: { - size?: number; - [key: string]: any; - }; } export interface ColorDefinition { primary: string; - background?: string; - text?: string; + background: string; + text: string; } export interface ThemeAwarePalette { @@ -42,14 +45,29 @@ export interface ThemeAwarePalette { export interface DashboardConfig { sections: DashboardSection[]; - style?: { - palette?: Record; + style: { + palette: Record; }; } export interface DashboardProps { config: DashboardConfig; data: ReportData; - isFetching?: boolean; + isFetching: boolean; onFlowChange?: (state: DashboardState) => void; } + + +export interface ComponentProps extends DashboardSection { + reportData: ReportData; + + state: DashboardState; + stateSetters: DashboardStateSetters; + isFetching: boolean; + + colorScheme: { + primary: string; + light: string; + text: string; + }; +} diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx index 4ecd870..8ca2035 100644 --- a/src/components/Dashboard/Dashboard.tsx +++ b/src/components/Dashboard/Dashboard.tsx @@ -1,8 +1,24 @@ import * as React from "react"; -import DashboardView from "./Dashboard.view"; -import { DashboardProps, DashboardState } from "./Dashboard.models"; +import { + Box, + Container, + Grid, + ToggleButton, + ToggleButtonGroup, + Button +} from "@mui/material"; +import { useTheme, alpha } from "@mui/material/styles"; +import { DashboardProps, DashboardState, DashboardStateSetters, DashboardFlow } from "./Dashboard.models"; + +export default function Dashboard({ + config, + data, + isFetching, + onFlowChange, +}: DashboardProps) { + const theme = useTheme(); + const themeMode = theme.palette.mode; -export default function Dashboard(props: DashboardProps) { const [state, setState] = React.useState({ flow: "outflows", periodType: "rolling", @@ -11,22 +27,38 @@ export default function Dashboard(props: DashboardProps) { comparison: false, }); - const toggleFlow = ( - event: React.MouseEvent, - newFlow: "outflows" | "inflows" | null - ) => { - if (newFlow === null) return; - + const toggleFlow = () => { setState(prev => { - if (prev.flow === newFlow) return prev; - - const next = { ...prev, flow: newFlow }; - props.onFlowChange?.(next); - - return next; + const nextFlow: DashboardFlow = prev.flow === "outflows" ? "inflows" : "outflows"; + const nextState: DashboardState = { + ...prev, + flow: nextFlow, + selectedGroupKey: null, + selectedPeriodId: null, + }; + onFlowChange?.(nextState); + return nextState; }); }; + const handleFlowChange = ( + _event: React.MouseEvent, + newFlow: DashboardFlow | null + ) => { + if (newFlow !== null && newFlow !== state.flow) { + setState(prev => { + const nextState: DashboardState = { + ...prev, + flow: newFlow, + selectedGroupKey: null, + selectedPeriodId: null, + }; + onFlowChange?.(nextState); + return nextState; + }); + } + }; + const togglePeriodType = () => { setState(prev => ({ ...prev, @@ -49,16 +81,122 @@ export default function Dashboard(props: DashboardProps) { setState(prev => ({ ...prev, selectedGroupKey: groupKey })); }; + const stateSetters: DashboardStateSetters = { + togglePeriodType, + toggleComparison, + toggleFlow, + setSelectedPeriodId, + setSelectedGroupKey, + }; + + const { flow, selectedGroupKey } = state; + + const colors = React.useMemo(() => { + const palette = config.style.palette[flow]; + const modeColors = palette[themeMode]; + + return { + primary: modeColors.primary, + light: modeColors.background || alpha(modeColors.primary, 0.1), + text: + modeColors.text || + (themeMode === "light" ? theme.palette.text.primary : "#fff"), + }; + + // if (modeColors) { + // return { + // primary: modeColors.primary, + // light: modeColors.background || alpha(modeColors.primary, 0.1), + // text: + // modeColors.text || + // (themeMode === "light" ? theme.palette.text.primary : "#fff"), + // }; + // } + // + // const themeColor = + // flow === "outflows" ? theme.palette.error : theme.palette.success; + // + // return { + // primary: themeColor.main, + // light: alpha(themeColor.main, themeMode === "light" ? 0.08 : 0.15), + // text: themeColor.main, + // }; + }, [config.style?.palette, flow, themeMode, theme.palette]); + return ( - + + + + Outflows + Inflows + + + {selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && ( + + )} + + + + {config.sections.map((section) => { + const Component = section.component; + + return ( + + + + ); + })} + + ); } diff --git a/src/components/Dashboard/Dashboard.view.tsx b/src/components/Dashboard/Dashboard.view.tsx deleted file mode 100644 index f3837fe..0000000 --- a/src/components/Dashboard/Dashboard.view.tsx +++ /dev/null @@ -1,143 +0,0 @@ -import * as React from "react"; -import { - Box, - Container, - Grid, - Typography, - ToggleButton, - ToggleButtonGroup, - Button -} 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 { - state: DashboardState; - setState: React.Dispatch>; - toggleFlow: (event: React.MouseEvent, newFlow: "outflows" | "inflows" | null) => void; - togglePeriodType: () => void; - setSelectedPeriodId: (id: string | null) => void; - setSelectedGroupKey: (groupKey: GroupKey | null) => void; - toggleComparison: () => void; -} - -export default function DashboardView({ - config, - data, - state, - setState, - toggleFlow, - togglePeriodType, - toggleComparison, - setSelectedPeriodId, - setSelectedGroupKey, -}: ViewProps) { - const theme = useTheme(); - const themeMode = theme.palette.mode; - const { flow, periodType, comparison, selectedPeriodId, selectedGroupKey } = state; - - // Resolve colors with fallbacks - const colors = React.useMemo(() => { - const palette = config.style?.palette?.[flow]; - const modeColors = palette ? palette[themeMode] : null; - - if (modeColors) { - return { - primary: modeColors.primary, - light: modeColors.background || alpha(modeColors.primary, 0.1), - text: modeColors.text || (themeMode === 'light' ? theme.palette.text.primary : '#fff') - }; - } - - // Fallback to standard theme colors - const themeColor = flow === 'outflows' ? theme.palette.error : theme.palette.success; - return { - primary: themeColor.main, - light: alpha(themeColor.main, themeMode === 'light' ? 0.08 : 0.15), - text: themeColor.main - }; - }, [config.style?.palette, flow, themeMode, theme.palette]); - - return ( - - - - Outflows - Inflows - - - {selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && ( - - )} - - - - {config.sections.map((section) => { - const Component = section.component; - - return ( - - - - ); - })} - - - ); -} diff --git a/src/components/HistoryChart/HistoryChart.models.ts b/src/components/HistoryChart/HistoryChart.models.ts index 0c63cc0..08f69f6 100644 --- a/src/components/HistoryChart/HistoryChart.models.ts +++ b/src/components/HistoryChart/HistoryChart.models.ts @@ -1,10 +1,3 @@ -import { - DashboardFlow, - DashboardPeriodType, - DashboardSelectedPeriodId -} from "../Dashboard"; -import { ReportData } from "../../features/report"; - export interface _ChartDataPoint { id: string; label: string; @@ -15,28 +8,3 @@ export interface _ChartDataPoint { export interface ChartDataPoint extends _ChartDataPoint { compare?: _ChartDataPoint; } - -export interface HistoryChartProps { - header: string; - summary?: string; - tabs: string[]; - - reportData: ReportData; - - colorScheme: { - primary: string; - light: string; - text: string; - }; - - flow: DashboardFlow; - periodType: DashboardPeriodType; - selectedPeriodId: DashboardSelectedPeriodId; - comparison: boolean; - - togglePeriodType: () => void; - setSelectedPeriodId: (id: string | null) => void; - toggleComparison: () => void; - - isFetching?: boolean; -} diff --git a/src/components/HistoryChart/HistoryChart.props.ts b/src/components/HistoryChart/HistoryChart.props.ts new file mode 100644 index 0000000..0e1cc61 --- /dev/null +++ b/src/components/HistoryChart/HistoryChart.props.ts @@ -0,0 +1,21 @@ +import * as React from "react"; +import { ComponentProps } from "../Dashboard"; +import { ChartDataPoint } from "./HistoryChart.models"; + +export interface HistoryChartProps extends ComponentProps { + settings: { + tabs: string[]; + }; +} + +export interface HistoryChartViewProps extends HistoryChartProps { + activeTab: string; + setActiveTab: (v: string) => void; + currentData: ChartDataPoint[]; + visibleData: ChartDataPoint[]; + maxAmount: number; + visibleCount: number; + startIndex: number; + setStartIndex: React.Dispatch>; + activeDataKey: string; +} diff --git a/src/components/HistoryChart/HistoryChart.tsx b/src/components/HistoryChart/HistoryChart.tsx index 98ba837..95291c6 100644 --- a/src/components/HistoryChart/HistoryChart.tsx +++ b/src/components/HistoryChart/HistoryChart.tsx @@ -1,18 +1,23 @@ import * as React from "react"; -import { HistoryChartProps } from "./HistoryChart.models"; import HistoryChartView from "./HistoryChart.view"; import { buildChartData, tabToKey } from "./HistoryChart.adapter"; +import { HistoryChartProps } from "./HistoryChart.props"; + export default function HistoryChart(props: HistoryChartProps) { const { - tabs, + settings, reportData, - flow, - comparison, - selectedPeriodId, - setSelectedPeriodId + state, + stateSetters, + + isFetching, } = props; + const { flow, comparison, selectedPeriodId } = state; + const { setSelectedPeriodId } = stateSetters; + const { tabs } = settings; + const [activeTab, setActiveTab] = React.useState(tabs[0] || ""); const [startIndex, setStartIndex] = React.useState(0); diff --git a/src/components/HistoryChart/HistoryChart.view.tsx b/src/components/HistoryChart/HistoryChart.view.tsx index 3b72287..c02923c 100644 --- a/src/components/HistoryChart/HistoryChart.view.tsx +++ b/src/components/HistoryChart/HistoryChart.view.tsx @@ -11,49 +11,34 @@ import IconButton from "@mui/material/IconButton"; import ChevronLeftIcon from "@mui/icons-material/ChevronLeft"; import ChevronRightIcon from "@mui/icons-material/ChevronRight"; import { - ChartDataPoint, - HistoryChartProps, -} from "./HistoryChart.models"; + HistoryChartViewProps, +} from "./HistoryChart.props"; import { formatDisplay } from "./HistoryChart.utils"; -interface ViewProps extends HistoryChartProps { - activeTab: string; - setActiveTab: (v: string) => void; - currentData: ChartDataPoint[]; - visibleData: ChartDataPoint[]; - maxAmount: number; - visibleCount: number; - startIndex: number; - setStartIndex: React.Dispatch>; - activeDataKey: string; -} +export default function HistoryChartView({ + title, + summary, + settings, -export default function HistoryChartView(props: ViewProps) { - const { - header, - summary, - tabs, - colorScheme, + state, + stateSetters, + isFetching, - flow, - periodType, - selectedPeriodId, - comparison, + colorScheme, - togglePeriodType, - setSelectedPeriodId, - toggleComparison, + activeTab, + setActiveTab, + currentData, + visibleData, + maxAmount, + visibleCount, + startIndex, + setStartIndex, + activeDataKey, +}: HistoryChartViewProps) { - activeTab, - setActiveTab, - currentData, - visibleData, - maxAmount, - visibleCount, - startIndex, - setStartIndex, - activeDataKey, - } = props; + const { flow, periodType, selectedPeriodId, comparison } = state; + const { togglePeriodType, setSelectedPeriodId, toggleComparison } = stateSetters; const theme = useTheme(); const isDark = theme.palette.mode === "dark"; @@ -92,13 +77,13 @@ export default function HistoryChartView(props: ViewProps) { border: "1px solid", borderColor: "divider", bgcolor: isDark ? "background.paper" : colorScheme.light, - opacity: props.isFetching ? 0.6 : 1, + opacity: isFetching ? 0.6 : 1, transition: "opacity 0.3s ease", - pointerEvents: props.isFetching ? "none" : "auto", + pointerEvents: isFetching ? "none" : "auto", }} > - {header} + {title} {summary && ( @@ -108,7 +93,7 @@ export default function HistoryChartView(props: ViewProps) { )} - {tabs.map((tab) => ( + {settings.tabs.map((tab) => ( {tab} diff --git a/src/components/LatestItems/LatestItems.adapter.ts b/src/components/LatestItems/LatestItems.adapter.ts index d35e8ec..5720ab6 100644 --- a/src/components/LatestItems/LatestItems.adapter.ts +++ b/src/components/LatestItems/LatestItems.adapter.ts @@ -1,67 +1,19 @@ -import { ReportData, Transaction, GroupKey } from "../../features/report"; +import { ReportData, GroupKey } from "../../features/report"; import { - mergeBucketPeriods, - periodIdToKey, formatCurrency, - filterBuckets, + extractFilteredTransactions, } from "../report.helpers"; import { LatestItem } from "./LatestItems.models"; -// ─── Transaction extraction ───────────────────────────────── - -function extractTransactions( - reportData: ReportData, - selectedPeriodId: string | null, - selectedGroupKey: GroupKey | null, -): Transaction[] { - // 1. Get raw transactions - let rawTxns: Transaction[] = []; - - if (selectedPeriodId) { - const key = periodIdToKey(selectedPeriodId); - const periods = mergeBucketPeriods(reportData.buckets, key); - const selected = periods.find((p) => p.id === selectedPeriodId); - rawTxns = selected?.metric.transactions || []; - } else { - const periods = mergeBucketPeriods(reportData.buckets, "all"); - if (periods.length > 0) { - rawTxns = periods[0].metric.transactions || []; - } - } - - // 2. Filter by group key - if (selectedGroupKey) { - rawTxns = rawTxns.filter(txn => { - let match = true; - if (selectedGroupKey.tags && selectedGroupKey.tags.length > 0) { - if (!txn.tags) match = false; - else { - const txnTags = txn.tags.map(t => typeof t === "string" ? t : t.name); - if (!selectedGroupKey.tags.every(selectedTag => txnTags.includes(selectedTag))) match = false; - } - } - if (match && selectedGroupKey.payee && selectedGroupKey.payee.length > 0) { - if (!txn.payee || !txn.payee.name) match = false; - else { - if (!selectedGroupKey.payee.includes(txn.payee.name)) match = false; - } - } - return match; - }); - } - - return rawTxns; -} - // ─── Main adapter ──────────────────────────────────────────── export function buildLatestItems( reportData: ReportData, - selectedPeriodId: string | null, - selectedGroupKey: GroupKey | null, + selectedPeriodId: string | null | undefined, + selectedGroupKey: GroupKey | null | undefined, flow: "outflows" | "inflows" ): LatestItem[] { - const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey); + const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey); return txns .sort( diff --git a/src/components/LatestItems/LatestItems.models.ts b/src/components/LatestItems/LatestItems.models.ts index a58bd99..336ea01 100644 --- a/src/components/LatestItems/LatestItems.models.ts +++ b/src/components/LatestItems/LatestItems.models.ts @@ -5,12 +5,3 @@ export interface LatestItem { amount: string; timeAgo: string; } - -export interface LatestItemsViewProps { - items: LatestItem[]; - header: string; - accentColor: string; - canExpand: boolean; - onExpand: () => void; - isFetching?: boolean; -} diff --git a/src/components/LatestItems/LatestItems.props.ts b/src/components/LatestItems/LatestItems.props.ts new file mode 100644 index 0000000..49c3721 --- /dev/null +++ b/src/components/LatestItems/LatestItems.props.ts @@ -0,0 +1,10 @@ +import { ComponentProps } from "../Dashboard"; +import { LatestItem } from "./LatestItems.models"; + +export interface LatestItemsProps extends ComponentProps {} + +export interface LatestItemsViewProps extends LatestItemsProps { + items: LatestItem[]; + canExpand: boolean; + onExpand: () => void; +} diff --git a/src/components/LatestItems/LatestItems.tsx b/src/components/LatestItems/LatestItems.tsx index badc744..0b182e3 100644 --- a/src/components/LatestItems/LatestItems.tsx +++ b/src/components/LatestItems/LatestItems.tsx @@ -1,29 +1,24 @@ import * as React from "react"; -import { ReportData, GroupKey } from "../../features/report"; import { buildLatestItems } from "./LatestItems.adapter"; import LatestItemsView from "./LatestItems.view"; +import { LatestItemsProps } from "./LatestItems.props"; -type Props = { - reportData: ReportData; - flow: "outflows" | "inflows"; - header: string; - selectedPeriodId: string | null; - selectedGroupKey?: GroupKey | null; - accentColor: string; - isFetching?: boolean; -}; +export default function LatestItems(props: LatestItemsProps) { + const { + reportData, + state, + stateSetters, + isFetching, + } = props; -export default function LatestItems({ - reportData, - flow, - header, - selectedPeriodId, - selectedGroupKey = null, - accentColor, - isFetching, -}: Props) { + const { flow, selectedPeriodId, selectedGroupKey } = state; const [visibleCount, setVisibleCount] = React.useState(5); + // Reset count when flow changes to start clean + React.useEffect(() => { + setVisibleCount(5); + }, [flow]); + const allItems = React.useMemo(() => { return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow); }, [reportData, selectedPeriodId, selectedGroupKey, flow]); @@ -36,11 +31,9 @@ export default function LatestItems({ return ( setVisibleCount((prev) => prev + 5)} /> ); diff --git a/src/components/LatestItems/LatestItems.view.tsx b/src/components/LatestItems/LatestItems.view.tsx index 2be8d7d..2932573 100644 --- a/src/components/LatestItems/LatestItems.view.tsx +++ b/src/components/LatestItems/LatestItems.view.tsx @@ -10,21 +10,23 @@ import { IconButton, } from "@mui/material"; import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; -import { LatestItemsViewProps } from "./LatestItems.models"; +import { LatestItemsViewProps } from "./LatestItems.props"; export default function LatestItemsView({ items, - header, - accentColor, + title, canExpand, onExpand, isFetching, + colorScheme, }: LatestItemsViewProps) { + const accentColor = colorScheme?.primary || ""; + return ( - {header} + {title} diff --git a/src/components/ProgressCard/ProgressCard.models.ts b/src/components/ProgressCard/ProgressCard.models.ts deleted file mode 100644 index a4f91f3..0000000 --- a/src/components/ProgressCard/ProgressCard.models.ts +++ /dev/null @@ -1,11 +0,0 @@ -export interface ProgressCardProps { - header: string; - summary?: string; - progressAmount: number; - totalAmount: number; - colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning"; - compact?: boolean; - selected?: boolean; - onClick?: () => void; - isFetching?: boolean; -} diff --git a/src/components/ProgressCard/ProgressCard.props.ts b/src/components/ProgressCard/ProgressCard.props.ts new file mode 100644 index 0000000..5ff8517 --- /dev/null +++ b/src/components/ProgressCard/ProgressCard.props.ts @@ -0,0 +1,14 @@ +import { ComponentProps } from "../Dashboard"; + +export interface ProgressCardProps extends ComponentProps { + settings: { + compact: boolean; + }; +} + +export interface ProgressCardViewProps extends ProgressCardProps { + progressAmount: number; + totalAmount: number; + selected: boolean; + onClick: () => void; +} diff --git a/src/components/ProgressCard/ProgressCard.tsx b/src/components/ProgressCard/ProgressCard.tsx deleted file mode 100644 index b42c2e4..0000000 --- a/src/components/ProgressCard/ProgressCard.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import * as React from "react"; -import ProgressCardView from "./ProgressCard.view"; -import { ProgressCardProps } from "./ProgressCard.models"; -import { getPercentage, formatCurrency } from "../report.helpers"; - -export default function ProgressCard(props: ProgressCardProps) { - const { progressAmount, totalAmount, compact = false } = props; - - const percentage = getPercentage(progressAmount, totalAmount); - - const formattedProgress = formatCurrency(progressAmount); - const formattedTotal = formatCurrency(totalAmount); - - return ( - - ); -} diff --git a/src/components/ProgressCard/ProgressCard.view.tsx b/src/components/ProgressCard/ProgressCard.view.tsx index 34472bf..d535550 100644 --- a/src/components/ProgressCard/ProgressCard.view.tsx +++ b/src/components/ProgressCard/ProgressCard.view.tsx @@ -8,93 +8,83 @@ import { linearProgressClasses } from "@mui/material"; import { useTheme, alpha } from "@mui/material/styles"; -import { ProgressCardProps } from "./ProgressCard.models"; - -interface ViewProps extends ProgressCardProps { - percentage: number; - formattedProgress: string; - formattedTotal: string; - selected?: boolean; - onClick?: () => void; -} +import { getPercentage, formatCurrency } from "../report.helpers"; +import { ProgressCardViewProps } from "./ProgressCard.props"; export default function ProgressCardView({ - header, - colorTheme = "info", - percentage, - formattedProgress, - formattedTotal, - compact = false, + title, + settings, + + isFetching, + + colorScheme, + + progressAmount, + totalAmount, selected, onClick, -}: ViewProps) { +}: ProgressCardViewProps) { const theme = useTheme(); const isDark = theme.palette.mode === "dark"; + const percentage = getPercentage(progressAmount, totalAmount); + const formattedProgress = formatCurrency(progressAmount); + const formattedTotal = formatCurrency(totalAmount); + return ( { - const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main; - const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light; - return isDark - ? `linear-gradient(135deg, ${alpha(baseColor, 0.9)} 0%, ${alpha(baseColor, 0.3)} 100%)` - : `linear-gradient(135deg, ${baseColor} 0%, ${lightColor} 100%)`; - }, - color: "#fff", + bgcolor: colorScheme.light, + color: colorScheme.text, display: "flex", flexDirection: "column", - alignItems: compact ? "flex-start" : "center", + alignItems: settings.compact ? "flex-start" : "center", justifyContent: "center", position: "relative", overflow: "hidden", - 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; - }, - opacity: arguments[0].isFetching ? 0.6 : 1, - pointerEvents: arguments[0].isFetching ? "none" : "auto", + border: selected + ? `2px solid ${colorScheme.primary}` + : isDark + ? "1px solid rgba(255,255,255,0.1)" + : "1px solid rgba(0,0,0,0.06)", + boxShadow: "none", + opacity: isFetching ? 0.6 : 1, + pointerEvents: isFetching ? "none" : "auto", }} > - - {header} + {title} - + {formattedProgress} @@ -102,38 +92,38 @@ export default function ProgressCardView({ of {formattedTotal} - + diff --git a/src/components/ProgressCard/TopPayees.adapter.ts b/src/components/ProgressCard/TopPayees.adapter.ts index af71bea..dffbdc9 100644 --- a/src/components/ProgressCard/TopPayees.adapter.ts +++ b/src/components/ProgressCard/TopPayees.adapter.ts @@ -1,5 +1,8 @@ -import { mergeBucketPeriods, periodIdToKey } from "../report.helpers"; import { GroupKey, ReportData } from "../../features/report"; +import { + extractFilteredTransactions, + aggregateTransactions, +} from "../report.helpers"; export interface PayeeItem { name: string; @@ -12,54 +15,17 @@ export function extractTopPayees( selectedPeriodId?: string | null, selectedGroupKey?: GroupKey | null ): { items: PayeeItem[]; total: number } { - const payeeMap = new Map(); + const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey); - let targetPeriods = []; - - if (selectedPeriodId) { - const key = periodIdToKey(selectedPeriodId); - const periods = mergeBucketPeriods(reportData.buckets, key); - const selected = periods.find((p) => p.id === selectedPeriodId); - if (selected) { - targetPeriods.push(selected); + const { items, total } = aggregateTransactions(txns, (txn) => { + if (txn.payee && txn.payee.name) { + return [txn.payee.name]; } - } else { - // If no specific period is selected, aggregate over the "all" period bucket - targetPeriods = mergeBucketPeriods(reportData.buckets, "all"); - } - - for (const p of targetPeriods) { - let txns = p.metric.transactions || []; - - if (selectedGroupKey?.tags && selectedGroupKey.tags.length > 0) { - txns = txns.filter(txn => { - if (!txn.tags) return false; - const txnTags = txn.tags.map(t => typeof t === "string" ? t : t.name); - return selectedGroupKey.tags!.every(selectedTag => txnTags.includes(selectedTag)); - }); - } - - for (const txn of txns) { - if (txn.payee && txn.payee.name) { - const current = payeeMap.get(txn.payee.name) || 0; - payeeMap.set(txn.payee.name, current + txn.amount); - } - } - } - - let items: PayeeItem[] = []; - let total = 0; - - for (const [name, amount] of payeeMap.entries()) { - items.push({ name, amount }); - total += amount; - } - - // Sort descending by amount - items.sort((a, b) => b.amount - a.amount); + return []; + }); return { - items: items.slice(0, 4), // Top 4 + items, total, }; } diff --git a/src/components/ProgressCard/TopPayees.tsx b/src/components/ProgressCard/TopPayees.tsx index ea161da..37786d5 100644 --- a/src/components/ProgressCard/TopPayees.tsx +++ b/src/components/ProgressCard/TopPayees.tsx @@ -1,30 +1,22 @@ import * as React from "react"; import { Box, Paper, Typography } from "@mui/material"; -import { ReportData, GroupKey } from "../../features/report"; -import ProgressCard from "./ProgressCard"; +import ProgressCardView from "./ProgressCard.view"; import { extractTopPayees } from "./TopPayees.adapter"; +import { ProgressCardProps } from "./ProgressCard.props"; -type Props = { - reportData: ReportData; - flow: "outflows" | "inflows"; - header: string; - selectedPeriodId?: string | null; - selectedGroupKey?: GroupKey | null; - setSelectedGroupKey?: (key: GroupKey | null) => void; - compact?: boolean; - isFetching?: boolean; -}; +export default function TopPayees(props: ProgressCardProps) { + const { + title, + + reportData, + state, + stateSetters, + + isFetching, + } = props + const { flow, selectedPeriodId, selectedGroupKey } = state; + const { setSelectedGroupKey } = stateSetters; -export default function TopPayees({ - reportData, - flow, - header, - selectedPeriodId, - selectedGroupKey, - setSelectedGroupKey, - compact = true, - isFetching, -}: Props) { const { items, total } = React.useMemo(() => { return extractTopPayees(reportData, flow, selectedPeriodId, selectedGroupKey); }, [reportData, flow, selectedPeriodId, selectedGroupKey]); @@ -45,7 +37,7 @@ export default function TopPayees({ }} > - {header} + {title} {items.map((item) => { - const isSelected = selectedGroupKey?.payee?.includes(item.name); + const isSelected = !!selectedGroupKey?.payee?.includes(item.name); return ( - { if (setSelectedGroupKey) { let newKey = selectedGroupKey ? { ...selectedGroupKey } : {}; diff --git a/src/components/ProgressCard/TopTags.adapter.ts b/src/components/ProgressCard/TopTags.adapter.ts index eb4cf1f..871fb94 100644 --- a/src/components/ProgressCard/TopTags.adapter.ts +++ b/src/components/ProgressCard/TopTags.adapter.ts @@ -1,11 +1,9 @@ -import { ReportData } from "../../features/report"; +import { ReportData, GroupKey } from "../../features/report"; import { - mergeBucketPeriods, - periodIdToKey, + extractFilteredTransactions, + aggregateTransactions, } from "../report.helpers"; -import { GroupKey } from "../../features/report"; - export interface TagItem { tag: string; amount: number; @@ -17,55 +15,17 @@ export function extractTopTags( selectedPeriodId?: string | null, selectedGroupKey?: GroupKey | null ): { items: TagItem[]; total: number } { - const tagMap = new Map(); + const txns = extractFilteredTransactions(reportData, selectedPeriodId, selectedGroupKey); - let periodKey: ReturnType = "all"; - if (selectedPeriodId) { - periodKey = periodIdToKey(selectedPeriodId); - } - - const periods = mergeBucketPeriods(reportData.buckets, periodKey); - - let period = periods[0]; - if (selectedPeriodId) { - period = periods.find(p => p.id === selectedPeriodId) || period; - } else if (periods.length > 0) { - period = periods.reduce((latest, p) => - new Date(p.start).getTime() > new Date(latest.start).getTime() - ? p - : latest - ); - } - - if (period && period.metric && period.metric.transactions) { - let txns = period.metric.transactions; - if (selectedGroupKey?.payee && selectedGroupKey.payee.length > 0) { - txns = txns.filter(txn => - txn.payee?.name && selectedGroupKey.payee!.includes(txn.payee.name) - ); + const { items, total } = aggregateTransactions(txns, (txn) => { + if (txn.tags && txn.tags.length > 0) { + return txn.tags.map((t) => (typeof t === "string" ? t : t.name)); } + return ["Untagged"]; + }); - for (const txn of txns) { - if (txn.tags && txn.tags.length > 0) { - for (const tagObj of txn.tags) { - const tagName = typeof tagObj === "string" ? tagObj : tagObj.name; - tagMap.set(tagName, (tagMap.get(tagName) || 0) + txn.amount); - } - } else { - tagMap.set("Untagged", (tagMap.get("Untagged") || 0) + txn.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 { + items: items.map((item) => ({ tag: item.name, amount: item.amount })), + total, + }; } diff --git a/src/components/ProgressCard/TopTags.tsx b/src/components/ProgressCard/TopTags.tsx index 08b2af3..402ba7a 100644 --- a/src/components/ProgressCard/TopTags.tsx +++ b/src/components/ProgressCard/TopTags.tsx @@ -1,30 +1,22 @@ import * as React from "react"; import { Box, Paper, Typography } from "@mui/material"; -import { ReportData, GroupKey } from "../../features/report"; -import ProgressCard from "./ProgressCard"; +import ProgressCardView from "./ProgressCard.view"; import { extractTopTags } from "./TopTags.adapter"; +import { ProgressCardProps } from "./ProgressCard.props"; -type Props = { - reportData: ReportData; - flow: "outflows" | "inflows"; - header: string; - selectedPeriodId?: string | null; - selectedGroupKey?: GroupKey | null; - setSelectedGroupKey?: (key: GroupKey | null) => void; - compact?: boolean; - isFetching?: boolean; -}; +export default function TopTags(props: ProgressCardProps) { + const { + title, + + reportData, + state, + stateSetters, + + isFetching, + } = props + const { flow, selectedPeriodId, selectedGroupKey } = state; + const { setSelectedGroupKey } = stateSetters; -export default function TopTags({ - reportData, - flow, - header, - selectedPeriodId, - selectedGroupKey, - setSelectedGroupKey, - compact = true, - isFetching, -}: Props) { const { items, total } = React.useMemo(() => { return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey); }, [reportData, flow, selectedPeriodId, selectedGroupKey]); @@ -45,7 +37,7 @@ export default function TopTags({ }} > - {header} + {title} {items.map((item) => { - const isSelected = selectedGroupKey?.tags?.includes(item.tag); + const isSelected = !!selectedGroupKey?.tags?.includes(item.tag); return ( - { if (setSelectedGroupKey) { let newKey = selectedGroupKey ? { ...selectedGroupKey } : {}; diff --git a/src/components/ProgressCard/index.ts b/src/components/ProgressCard/index.ts index f242754..c2d6d76 100644 --- a/src/components/ProgressCard/index.ts +++ b/src/components/ProgressCard/index.ts @@ -1,2 +1,2 @@ -export { default } from "./ProgressCard"; -export * from "./ProgressCard.models"; +export { default } from "./ProgressCard.view"; +export * from "./ProgressCard.props"; diff --git a/src/components/report.helpers.ts b/src/components/report.helpers.ts index 0b21725..df441a2 100644 --- a/src/components/report.helpers.ts +++ b/src/components/report.helpers.ts @@ -3,6 +3,8 @@ import { ReportBucket, GroupKey, PeriodType, + ReportData, + Transaction, } from "../features/report"; // ─── Types ──────────────────────────────────────────────────── @@ -140,3 +142,89 @@ export function filterBuckets( if (!selectedGroupKey) return buckets; return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey)); } + +export function extractFilteredTransactions( + reportData: ReportData, + selectedPeriodId: string | null | undefined, + selectedGroupKey: GroupKey | null | undefined +): Transaction[] { + let txns: Transaction[] = []; + + if (selectedPeriodId) { + const key = periodIdToKey(selectedPeriodId); + const periods = mergeBucketPeriods(reportData.buckets, key); + const selected = periods.find((p) => p.id === selectedPeriodId); + txns = selected?.metric.transactions || []; + } else { + const periods = mergeBucketPeriods(reportData.buckets, "all"); + if (periods.length > 0) { + const period = periods.reduce((latest, p) => + new Date(p.start).getTime() > new Date(latest.start).getTime() + ? p + : latest + , periods[0]); + txns = period?.metric.transactions || []; + } + } + + if (selectedGroupKey) { + txns = txns.filter((txn) => { + let match = true; + if (selectedGroupKey.tags && selectedGroupKey.tags.length > 0) { + if (!txn.tags) { + match = false; + } else { + const txnTags = txn.tags.map((t: any) => + typeof t === "string" ? t : t.name + ); + if ( + !selectedGroupKey.tags.every((selectedTag) => + txnTags.includes(selectedTag) + ) + ) { + match = false; + } + } + } + if (match && selectedGroupKey.payee && selectedGroupKey.payee.length > 0) { + if (!txn.payee || !txn.payee.name) { + match = false; + } else { + if (!selectedGroupKey.payee.includes(txn.payee.name)) { + match = false; + } + } + } + return match; + }); + } + + return txns; +} + +export function aggregateTransactions( + transactions: Transaction[], + keyExtractor: (txn: Transaction) => string[], + limit = 4 +): { items: { name: string; amount: number }[]; total: number } { + const map = new Map(); + + for (const txn of transactions) { + const keys = keyExtractor(txn); + for (const key of keys) { + map.set(key, (map.get(key) || 0) + txn.amount); + } + } + + const items = Array.from(map.entries()).map(([name, amount]) => ({ + name, + amount, + })); + + items.sort((a, b) => b.amount - a.amount); + + const top = items.slice(0, limit); + const total = top.reduce((sum, item) => sum + item.amount, 0); + + return { items: top, total }; +} diff --git a/src/dashboard-config.ts b/src/dashboard-config.ts index 8f2b7f6..43dc137 100644 --- a/src/dashboard-config.ts +++ b/src/dashboard-config.ts @@ -14,9 +14,6 @@ export const configuration: DashboardConfig = { settings: { tabs: ["Weekly", "Monthly"], }, - style: { - size: 12, - }, }, { id: "top-categories", @@ -25,9 +22,6 @@ export const configuration: DashboardConfig = { settings: { compact: true, }, - style: { - size: 12, - }, }, { id: "top-payees", @@ -36,17 +30,11 @@ export const configuration: DashboardConfig = { settings: { compact: true, }, - style: { - size: 12, - }, }, { id: "items", title: 'Recent Transactions', component: LatestItems, - style: { - size: 12, - }, }, ], style: {