From 8bea3d06f6443ffb6319e719145317b2d05ad7f7 Mon Sep 17 00:00:00 2001 From: Vishesh 'ironeagle' Bangotra Date: Mon, 18 May 2026 05:37:51 +0000 Subject: [PATCH] Dashboard Refactor: Flow-based Metrics + Unified Data Model (#4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit # Dashboard Refactor: Flow-based Metrics + Unified Data Model ## Summary This MR transforms the dashboard into a **flow-driven, backend-powered analytics system** with a significantly cleaner architecture and improved UX. ## Overview This MR introduces a **major refactor of the dashboard and report data model**, transitioning from separate `expense/income` handling to a unified **flow-based (`outflows` / `inflows`) system** backed by a single `metric` structure. It simplifies data handling, improves UI consistency, and enables better extensibility for future analytics. --- ## Key Changes ### 1. Data Model Simplification * Replaced: * `expenses` / `incomes` * With: * `metric` ```ts ReportPeriod { start: string; end: string; metric: { sum: number; count: number; transactions: Transaction[]; } } ``` * Eliminates duplication across logic paths * Flow is now controlled at query level instead of data shape --- ### 2. Flow-based System (Core Change) * Introduced: ```ts type DashboardFlow = "outflows" | "inflows"; ``` * Replaced all references of: * `expense` → `outflows` * `income` → `inflows` * Flow is now: * Controlled at **Dashboard level** * Propagated to **API query (`useReport`)** --- ### 3. API Changes #### `useReport` * Removed legacy params: * `group_by`, `rolling`, `include_transactions`, etc. * New structure: ```ts useReport({ periods: ["daily", "weekly", "monthly", "all"], flow, payee, tags }) ``` * Backend now handles: * Flow filtering * Aggregation --- ### 4. Period System Update * Removed: * yearly, fyly, full * Added: * `daily` * `all` ```ts type PeriodType = "daily" | "weekly" | "monthly" | "all"; ``` * Updated helpers: * `periodIdToKey` * `buildPeriodId` * `buildLabel` --- ### 5. React Query UX Improvement * Added: ```ts placeholderData: keepPreviousData ``` * Prevents UI flicker on filter/flow changes * Enables smooth transitions --- ### 6. Dashboard State Refactor #### Before ```ts mode: "expense" | "income" ``` #### After ```ts flow: "outflows" | "inflows" ``` * Introduced `onFlowChange` callback * Lifted flow state to parent (`Dashboard.tsx`) * Flow change triggers API refetch --- ### 7. UI Improvements #### Flow Toggle * Replaced mode toggle with: * Outflows / Inflows switch #### Loading State Handling * Added `isFetching` across components * UI behavior during fetch: * Reduced opacity * Disabled interactions #### Drill-down UX * Added: * "Clear Drill-down" button --- ### 8. New Components #### TopPayees * New analytics card * Shows top payees based on: * Selected period * Drill-down filters * Supports: * Click-to-filter (drill-down) --- ### 9. Adapter Layer Simplification #### Removed mode branching everywhere Examples: * `getAmount(period)` now uses: ```ts period.metric.sum ``` * `LatestItems`, `TopTags`, `HistoryChart`: * No longer split logic by expense/income * Work on unified transaction stream --- ### 10. GroupKey Generalization #### Before ```ts { payee?: string[]; tags?: string[]; } ``` #### After ```ts { [dimension: string]: string[]; } ``` * Enables future dimensions without refactor --- ## Behavioral Changes * Flow selection now **controls backend query** * All components consume **filtered data only** * No client-side filtering for expense/income --- ## Benefits * Single source of truth (`metric`) * Cleaner adapters (no branching explosion) * Easier feature additions (new dimensions, filters) * Better UX (no flicker, smoother transitions) * Backend-driven correctness --- ## Migration Notes * Replace all `mode` usages with `flow` * Update adapters to use `metric` * Remove assumptions about: * `expenses` * `incomes` * Ensure API supports: * `flow` * new period types --- ## Future Scope * Add more dimensions (account, category hierarchy) * Multi-flow comparison (inflows vs outflows together) * Snapshot-based caching (already partially supported) --- ## Testing Notes Verify: * Flow toggle updates API calls * No UI flicker on filter change * Drill-down works across: * tags * payees * Daily / Weekly / Monthly / All tabs behave correctly * Loading state disables interaction properly --- Reviewed-on: https://git.aetoskia.com/apps/khata-ui/pulls/4 Co-authored-by: Vishesh 'ironeagle' Bangotra Co-committed-by: Vishesh 'ironeagle' Bangotra --- react-openapi/hooks/useResource.ts | 3 +- src/Dashboard.tsx | 27 ++--- src/components/Dashboard/Dashboard.models.ts | 8 +- src/components/Dashboard/Dashboard.tsx | 23 +++-- src/components/Dashboard/Dashboard.view.tsx | 46 +++++---- .../HistoryChart/HistoryChart.adapter.ts | 14 ++- .../HistoryChart/HistoryChart.models.ts | 6 +- src/components/HistoryChart/HistoryChart.tsx | 11 +-- .../HistoryChart/HistoryChart.view.tsx | 5 +- .../LatestItems/LatestItems.adapter.ts | 53 ++++++---- .../LatestItems/LatestItems.models.ts | 2 + src/components/LatestItems/LatestItems.tsx | 21 ++-- .../LatestItems/LatestItems.view.tsx | 6 +- .../ProgressCard/ProgressCard.models.ts | 1 + .../ProgressCard/ProgressCard.view.tsx | 2 + .../ProgressCard/TopPayees.adapter.ts | 65 ++++++++++++ src/components/ProgressCard/TopPayees.tsx | 93 ++++++++++++++++++ .../ProgressCard/TopTags.adapter.ts | 77 +++++++-------- src/components/ProgressCard/TopTags.tsx | 98 ++++++++++++------- src/components/report.helpers.ts | 27 +++-- src/dashboard-config.ts | 18 +++- src/features/report/index.ts | 2 + src/features/report/report.models.ts | 67 ++++++++----- src/features/report/report.utils.ts | 38 +++---- src/features/report/useReport.ts | 16 +-- 25 files changed, 478 insertions(+), 251 deletions(-) create mode 100644 src/components/ProgressCard/TopPayees.adapter.ts create mode 100644 src/components/ProgressCard/TopPayees.tsx diff --git a/react-openapi/hooks/useResource.ts b/react-openapi/hooks/useResource.ts index 8934129..715332d 100644 --- a/react-openapi/hooks/useResource.ts +++ b/react-openapi/hooks/useResource.ts @@ -1,4 +1,4 @@ -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useQuery, useMutation, useQueryClient, keepPreviousData } from "@tanstack/react-query"; import { api } from "../api/client"; import { ResourceConfig } from "../types/config"; import { ConfigContext } from "../providers/ConfigContext"; @@ -26,6 +26,7 @@ export function useResource(config: ResourceConfig | undefined) { }; }, enabled: !!endpoint, + placeholderData: keepPreviousData, }); // --- READ ONE --- diff --git a/src/Dashboard.tsx b/src/Dashboard.tsx index 1e5cecf..ffcf1e9 100644 --- a/src/Dashboard.tsx +++ b/src/Dashboard.tsx @@ -11,6 +11,7 @@ import { } from "@mui/material"; import ConfigurableDashboard from "./components/Dashboard"; +import { DashboardState } from "./components/Dashboard"; import { configuration } from "./dashboard-config"; import { useReport, @@ -18,6 +19,8 @@ import { } from "./features/report"; export default function Dashboard() { + const [flow, setFlow] = React.useState<"outflows" | "inflows">("outflows"); + const [appliedPayees, setAppliedPayees] = React.useState([]); const [appliedTags, setAppliedTags] = React.useState([]); @@ -28,10 +31,8 @@ export default function Dashboard() { const [loadedTags, setLoadedTags] = React.useState([]); const report = useReport({ - periods: ["weekly", "monthly", "full"], - rolling: true, - include_transactions: true, - group_by: ["tags"], + periods: ["daily", "weekly", "monthly", "all"], + flow: flow, payee: appliedPayees.length > 0 ? appliedPayees : undefined, tags: appliedTags.length > 0 ? appliedTags : undefined, }); @@ -43,10 +44,7 @@ export default function Dashboard() { report.data.data.buckets.forEach((b: any) => { Object.values(b.periods).forEach((periodArray: any) => { periodArray?.forEach((p: any) => { - p.expenses?.transactions?.forEach((t: any) => { - if (t.payee?.name) pSet.add(t.payee.name); - }); - p.incomes?.transactions?.forEach((t: any) => { + p.metric?.transactions?.forEach((t: any) => { if (t.payee?.name) pSet.add(t.payee.name); }); }); @@ -60,10 +58,7 @@ export default function Dashboard() { report.data.data.buckets.forEach((b: any) => { Object.values(b.periods).forEach((periodArray: any) => { periodArray?.forEach((p: any) => { - p.expenses?.transactions?.forEach((t: any) => { - t.tags?.forEach((tag: any) => tSet.add(tag.name || tag)); - }); - p.incomes?.transactions?.forEach((t: any) => { + p.metric?.transactions?.forEach((t: any) => { t.tags?.forEach((tag: any) => tSet.add(tag.name || tag)); }); }); @@ -77,6 +72,10 @@ export default function Dashboard() { const isLoading = report.isLoading; const error = report.error; + /** Callback for the ConfigurableDashboard's flow toggle */ + const handleFlowChange = React.useCallback((newState: DashboardState) => { + setFlow(newState.flow); + }, []); if (isLoading && !report.data) { return ( @@ -152,7 +151,7 @@ export default function Dashboard() { setAppliedTags(tagsInput); }} disabled={isLoading} - sx={{ height: 40, borderRadius: 2 }} // Changed from 56 to 40 to match minHeight of inputs + sx={{ height: 40, borderRadius: 2 }} > Apply @@ -161,6 +160,8 @@ export default function Dashboard() { ); diff --git a/src/components/Dashboard/Dashboard.models.ts b/src/components/Dashboard/Dashboard.models.ts index 58b828d..e22879e 100644 --- a/src/components/Dashboard/Dashboard.models.ts +++ b/src/components/Dashboard/Dashboard.models.ts @@ -4,12 +4,12 @@ import { GroupKey, } from "../../features/report"; -export type DashboardMode = "expense" | "income"; +export type DashboardFlow = "outflows" | "inflows"; export type DashboardPeriodType = "rolling" | "calendar"; export type DashboardSelectedPeriodId = string | null; export interface DashboardState { - mode: DashboardMode; + flow: DashboardFlow; periodType: DashboardPeriodType; selectedPeriodId: DashboardSelectedPeriodId; selectedGroupKey: GroupKey | null; @@ -43,11 +43,13 @@ export interface ThemeAwarePalette { export interface DashboardConfig { sections: DashboardSection[]; style?: { - palette?: Record; + palette?: Record; }; } export interface DashboardProps { config: DashboardConfig; data: ReportData; + isFetching?: boolean; + onFlowChange?: (state: DashboardState) => void; } diff --git a/src/components/Dashboard/Dashboard.tsx b/src/components/Dashboard/Dashboard.tsx index e7b6cbb..4ecd870 100644 --- a/src/components/Dashboard/Dashboard.tsx +++ b/src/components/Dashboard/Dashboard.tsx @@ -4,18 +4,27 @@ import { DashboardProps, DashboardState } from "./Dashboard.models"; export default function Dashboard(props: DashboardProps) { const [state, setState] = React.useState({ - mode: "expense", + flow: "outflows", periodType: "rolling", selectedPeriodId: null, selectedGroupKey: null, comparison: false, }); - const toggleMode = () => { - setState(prev => ({ - ...prev, - mode: prev.mode === "expense" ? "income" : "expense", - })); + const toggleFlow = ( + event: React.MouseEvent, + newFlow: "outflows" | "inflows" | null + ) => { + if (newFlow === null) return; + + setState(prev => { + if (prev.flow === newFlow) return prev; + + const next = { ...prev, flow: newFlow }; + props.onFlowChange?.(next); + + return next; + }); }; const togglePeriodType = () => { @@ -45,7 +54,7 @@ export default function Dashboard(props: DashboardProps) { {...props} state={state} setState={setState} - toggleMode={toggleMode} + toggleFlow={toggleFlow} togglePeriodType={togglePeriodType} toggleComparison={toggleComparison} setSelectedPeriodId={setSelectedPeriodId} diff --git a/src/components/Dashboard/Dashboard.view.tsx b/src/components/Dashboard/Dashboard.view.tsx index 55793e2..f3837fe 100644 --- a/src/components/Dashboard/Dashboard.view.tsx +++ b/src/components/Dashboard/Dashboard.view.tsx @@ -5,7 +5,8 @@ import { Grid, Typography, ToggleButton, - ToggleButtonGroup + ToggleButtonGroup, + Button } from "@mui/material"; import { useTheme, alpha } from "@mui/material/styles"; import { GroupKey } from "../../features/report"; @@ -14,7 +15,7 @@ import { DashboardProps, DashboardState } from "./Dashboard.models"; interface ViewProps extends DashboardProps { state: DashboardState; setState: React.Dispatch>; - toggleMode: () => void; + toggleFlow: (event: React.MouseEvent, newFlow: "outflows" | "inflows" | null) => void; togglePeriodType: () => void; setSelectedPeriodId: (id: string | null) => void; setSelectedGroupKey: (groupKey: GroupKey | null) => void; @@ -26,7 +27,7 @@ export default function DashboardView({ data, state, setState, - toggleMode, + toggleFlow, togglePeriodType, toggleComparison, setSelectedPeriodId, @@ -34,11 +35,11 @@ export default function DashboardView({ }: ViewProps) { const theme = useTheme(); const themeMode = theme.palette.mode; - const { mode, periodType, comparison, selectedPeriodId, selectedGroupKey } = state; + const { flow, periodType, comparison, selectedPeriodId, selectedGroupKey } = state; // Resolve colors with fallbacks const colors = React.useMemo(() => { - const palette = config.style?.palette?.[mode]; + const palette = config.style?.palette?.[flow]; const modeColors = palette ? palette[themeMode] : null; if (modeColors) { @@ -50,13 +51,13 @@ export default function DashboardView({ } // Fallback to standard theme colors - const themeColor = mode === 'expense' ? theme.palette.error : theme.palette.success; + 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, mode, themeMode, theme.palette]); + }, [config.style?.palette, flow, themeMode, theme.palette]); return ( - + - Expenses - Income + Outflows + Inflows + + {selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && ( + + )} @@ -100,14 +111,6 @@ export default function DashboardView({ return ( - {section.title && !section.isList && ( - - - {section.title} - - - )} - ); diff --git a/src/components/HistoryChart/HistoryChart.adapter.ts b/src/components/HistoryChart/HistoryChart.adapter.ts index 8f17dca..bb660e1 100644 --- a/src/components/HistoryChart/HistoryChart.adapter.ts +++ b/src/components/HistoryChart/HistoryChart.adapter.ts @@ -9,15 +9,14 @@ import { ChartDataPoint } from "./HistoryChart.models"; // ─── Tab → PeriodKey ───────────────────────────────────────── const TAB_TO_KEY: Record = { + Daily: "daily", Weekly: "weekly", Monthly: "monthly", - Yearly: "yearly", - "Financial Year": "fyly", - "All Time": "full", + "All Time": "all", }; export function tabToKey(tab: string): PeriodKey { - return TAB_TO_KEY[tab] ?? "full"; + return TAB_TO_KEY[tab] ?? "all"; } // ─── Comparison ────────────────────────────────────────────── @@ -27,10 +26,9 @@ function attachComparison( key: PeriodKey ): ChartDataPoint[] { const getCompareIndex = (i: number) => { + if (key === "daily") return i - 7; 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; }; @@ -56,7 +54,7 @@ function attachComparison( export function buildChartData( reportData: ReportData, key: PeriodKey, - mode: "expense" | "income", + flow: "outflows" | "inflows", comparison: boolean ): ChartDataPoint[] { const merged = mergeBucketPeriods(reportData.buckets, key); @@ -64,7 +62,7 @@ export function buildChartData( let points: ChartDataPoint[] = merged.map((p) => ({ id: p.id, label: p.label, - amount: getAmount(p, mode), + amount: getAmount(p), })); if (comparison) { diff --git a/src/components/HistoryChart/HistoryChart.models.ts b/src/components/HistoryChart/HistoryChart.models.ts index cf6e2fc..0c63cc0 100644 --- a/src/components/HistoryChart/HistoryChart.models.ts +++ b/src/components/HistoryChart/HistoryChart.models.ts @@ -1,5 +1,5 @@ import { - DashboardMode, + DashboardFlow, DashboardPeriodType, DashboardSelectedPeriodId } from "../Dashboard"; @@ -29,7 +29,7 @@ export interface HistoryChartProps { text: string; }; - mode: DashboardMode; + flow: DashboardFlow; periodType: DashboardPeriodType; selectedPeriodId: DashboardSelectedPeriodId; comparison: boolean; @@ -37,4 +37,6 @@ export interface HistoryChartProps { togglePeriodType: () => void; setSelectedPeriodId: (id: string | null) => void; toggleComparison: () => void; + + isFetching?: boolean; } diff --git a/src/components/HistoryChart/HistoryChart.tsx b/src/components/HistoryChart/HistoryChart.tsx index 09f8124..98ba837 100644 --- a/src/components/HistoryChart/HistoryChart.tsx +++ b/src/components/HistoryChart/HistoryChart.tsx @@ -7,7 +7,7 @@ export default function HistoryChart(props: HistoryChartProps) { const { tabs, reportData, - mode, + flow, comparison, selectedPeriodId, setSelectedPeriodId @@ -19,8 +19,8 @@ export default function HistoryChart(props: HistoryChartProps) { const activeDataKey = tabToKey(activeTab); const currentData = React.useMemo(() => { - return buildChartData(reportData, activeDataKey, mode, comparison); - }, [reportData, activeDataKey, mode, comparison]); + return buildChartData(reportData, activeDataKey, flow, comparison); + }, [reportData, activeDataKey, flow, comparison]); const maxAmount = currentData.length > 0 @@ -35,11 +35,10 @@ export default function HistoryChart(props: HistoryChartProps) { : 1; const visibleCountMap = { + daily: 7, weekly: 6, monthly: 4, - yearly: 4, - fyly: 4, - full: 4, + all: 4, }; const visibleCount = visibleCountMap[activeDataKey] ?? 4; diff --git a/src/components/HistoryChart/HistoryChart.view.tsx b/src/components/HistoryChart/HistoryChart.view.tsx index 3dd3205..3b72287 100644 --- a/src/components/HistoryChart/HistoryChart.view.tsx +++ b/src/components/HistoryChart/HistoryChart.view.tsx @@ -35,7 +35,7 @@ export default function HistoryChartView(props: ViewProps) { tabs, colorScheme, - mode, + flow, periodType, selectedPeriodId, comparison, @@ -92,6 +92,9 @@ export default function HistoryChartView(props: ViewProps) { border: "1px solid", borderColor: "divider", bgcolor: isDark ? "background.paper" : colorScheme.light, + opacity: props.isFetching ? 0.6 : 1, + transition: "opacity 0.3s ease", + pointerEvents: props.isFetching ? "none" : "auto", }} > diff --git a/src/components/LatestItems/LatestItems.adapter.ts b/src/components/LatestItems/LatestItems.adapter.ts index d8236ee..d35e8ec 100644 --- a/src/components/LatestItems/LatestItems.adapter.ts +++ b/src/components/LatestItems/LatestItems.adapter.ts @@ -13,30 +13,44 @@ function extractTransactions( reportData: ReportData, selectedPeriodId: string | null, selectedGroupKey: GroupKey | null, - mode: "expense" | "income" ): Transaction[] { - const buckets = filterBuckets(reportData.buckets, selectedGroupKey); + // 1. Get raw transactions + let rawTxns: Transaction[] = []; + if (selectedPeriodId) { const key = periodIdToKey(selectedPeriodId); - const periods = mergeBucketPeriods(buckets, key); + const periods = mergeBucketPeriods(reportData.buckets, key); const selected = periods.find((p) => p.id === selectedPeriodId); - - if (!selected) return []; - - return mode === "expense" - ? (selected.expenses.transactions || []) - : (selected.incomes.transactions || []); + rawTxns = selected?.metric.transactions || []; + } else { + const periods = mergeBucketPeriods(reportData.buckets, "all"); + if (periods.length > 0) { + rawTxns = periods[0].metric.transactions || []; + } } - const periods = mergeBucketPeriods(buckets, "full"); + // 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; + }); + } - if (!periods.length) return []; - - const full = periods[0]; - - return mode === "expense" - ? (full.expenses.transactions || []) - : (full.incomes.transactions || []); + return rawTxns; } // ─── Main adapter ──────────────────────────────────────────── @@ -45,12 +59,11 @@ export function buildLatestItems( reportData: ReportData, selectedPeriodId: string | null, selectedGroupKey: GroupKey | null, - mode: "expense" | "income" + flow: "outflows" | "inflows" ): LatestItem[] { - const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode); + const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey); return txns - .filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0)) .sort( (a, b) => new Date(b.occurred_at).getTime() - diff --git a/src/components/LatestItems/LatestItems.models.ts b/src/components/LatestItems/LatestItems.models.ts index d953bdf..a58bd99 100644 --- a/src/components/LatestItems/LatestItems.models.ts +++ b/src/components/LatestItems/LatestItems.models.ts @@ -8,7 +8,9 @@ export interface LatestItem { export interface LatestItemsViewProps { items: LatestItem[]; + header: string; accentColor: string; canExpand: boolean; onExpand: () => void; + isFetching?: boolean; } diff --git a/src/components/LatestItems/LatestItems.tsx b/src/components/LatestItems/LatestItems.tsx index 99c4712..badc744 100644 --- a/src/components/LatestItems/LatestItems.tsx +++ b/src/components/LatestItems/LatestItems.tsx @@ -5,39 +5,42 @@ import LatestItemsView from "./LatestItems.view"; type Props = { reportData: ReportData; - mode: "expense" | "income"; + flow: "outflows" | "inflows"; + header: string; selectedPeriodId: string | null; selectedGroupKey?: GroupKey | null; accentColor: string; + isFetching?: boolean; }; export default function LatestItems({ reportData, - mode, + flow, + header, selectedPeriodId, selectedGroupKey = null, accentColor, + isFetching, }: 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); + return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow); + }, [reportData, selectedPeriodId, selectedGroupKey, flow]); const visibleItems = React.useMemo(() => { - if (!hasSelection) return allItems.slice(0, 5); return allItems.slice(0, visibleCount); - }, [allItems, hasSelection, visibleCount]); + }, [allItems, visibleCount]); - const canExpand = hasSelection && visibleCount < allItems.length; + const canExpand = visibleCount < allItems.length; return ( setVisibleCount((prev) => prev + 5)} /> ); diff --git a/src/components/LatestItems/LatestItems.view.tsx b/src/components/LatestItems/LatestItems.view.tsx index f29a6ae..2be8d7d 100644 --- a/src/components/LatestItems/LatestItems.view.tsx +++ b/src/components/LatestItems/LatestItems.view.tsx @@ -14,15 +14,17 @@ import { LatestItemsViewProps } from "./LatestItems.models"; export default function LatestItemsView({ items, + header, accentColor, canExpand, onExpand, + isFetching, }: LatestItemsViewProps) { return ( - + - Recent Transactions + {header} diff --git a/src/components/ProgressCard/ProgressCard.models.ts b/src/components/ProgressCard/ProgressCard.models.ts index c50984c..a4f91f3 100644 --- a/src/components/ProgressCard/ProgressCard.models.ts +++ b/src/components/ProgressCard/ProgressCard.models.ts @@ -7,4 +7,5 @@ export interface ProgressCardProps { compact?: boolean; selected?: boolean; onClick?: () => void; + isFetching?: boolean; } diff --git a/src/components/ProgressCard/ProgressCard.view.tsx b/src/components/ProgressCard/ProgressCard.view.tsx index 172d93b..34472bf 100644 --- a/src/components/ProgressCard/ProgressCard.view.tsx +++ b/src/components/ProgressCard/ProgressCard.view.tsx @@ -69,6 +69,8 @@ export default function ProgressCardView({ ? `${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", }} > (); + + 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); + } + } 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 { + items: items.slice(0, 4), // Top 4 + total, + }; +} diff --git a/src/components/ProgressCard/TopPayees.tsx b/src/components/ProgressCard/TopPayees.tsx new file mode 100644 index 0000000..ea161da --- /dev/null +++ b/src/components/ProgressCard/TopPayees.tsx @@ -0,0 +1,93 @@ +import * as React from "react"; +import { Box, Paper, Typography } from "@mui/material"; +import { ReportData, GroupKey } from "../../features/report"; +import ProgressCard from "./ProgressCard"; +import { extractTopPayees } from "./TopPayees.adapter"; + +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({ + 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]); + + return ( + + + {header} + + + + {items.map((item) => { + const isSelected = selectedGroupKey?.payee?.includes(item.name); + return ( + { + if (setSelectedGroupKey) { + let newKey = selectedGroupKey ? { ...selectedGroupKey } : {}; + + if (isSelected) { + delete newKey.payee; + } else { + newKey.payee = [item.name]; + } + + setSelectedGroupKey(Object.keys(newKey).length ? newKey : null); + } + }} + /> + ); + })} + + + ); +} diff --git a/src/components/ProgressCard/TopTags.adapter.ts b/src/components/ProgressCard/TopTags.adapter.ts index ea0eb63..eb4cf1f 100644 --- a/src/components/ProgressCard/TopTags.adapter.ts +++ b/src/components/ProgressCard/TopTags.adapter.ts @@ -1,31 +1,10 @@ import { ReportData } from "../../features/report"; import { - getAmount, - DecoratedPeriod, + mergeBucketPeriods, + periodIdToKey, } from "../report.helpers"; -// ─── Helpers ───────────────────────────────────────────────── - -function findPeriod( - periods: DecoratedPeriod[], - selectedPeriodId?: string | null -) { - if (!periods.length) return null; - - if (selectedPeriodId) { - const match = periods.find((p) => p.id === selectedPeriodId); - if (match) return match; - } - - // fallback → latest - return periods.reduce((latest, p) => - new Date(p.start).getTime() > new Date(latest.start).getTime() - ? p - : latest - ); -} - -// ─── Main adapter ──────────────────────────────────────────── +import { GroupKey } from "../../features/report"; export interface TagItem { tag: string; @@ -34,29 +13,47 @@ export interface TagItem { export function extractTopTags( reportData: ReportData, - mode: "expense" | "income", - selectedPeriodId?: string | null + flow: "outflows" | "inflows", + selectedPeriodId?: string | null, + selectedGroupKey?: GroupKey | 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; + let periodKey: ReturnType = "all"; + if (selectedPeriodId) { + periodKey = periodIdToKey(selectedPeriodId); + } - // Prefer FULL if available - const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[]; + const periods = mergeBucketPeriods(reportData.buckets, periodKey); - const periodsToUse = selectedPeriodId - ? (Object.values(bucket.periods).flat() as DecoratedPeriod[]) - : fullPeriods; + 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 + ); + } - const period = findPeriod(periodsToUse, selectedPeriodId); - if (!period) continue; + 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 amount = getAmount(period, mode); - - for (const tag of tags) { - tagMap.set(tag, (tagMap.get(tag) || 0) + amount); + 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); + } } } diff --git a/src/components/ProgressCard/TopTags.tsx b/src/components/ProgressCard/TopTags.tsx index 48c1e4b..08b2af3 100644 --- a/src/components/ProgressCard/TopTags.tsx +++ b/src/components/ProgressCard/TopTags.tsx @@ -1,61 +1,93 @@ import * as React from "react"; -import { Box } from "@mui/material"; +import { Box, Paper, Typography } from "@mui/material"; import { ReportData, GroupKey } from "../../features/report"; import ProgressCard from "./ProgressCard"; import { extractTopTags } from "./TopTags.adapter"; type Props = { reportData: ReportData; - mode: "expense" | "income"; + flow: "outflows" | "inflows"; + header: string; selectedPeriodId?: string | null; selectedGroupKey?: GroupKey | null; setSelectedGroupKey?: (key: GroupKey | null) => void; compact?: boolean; + isFetching?: boolean; }; export default function TopTags({ reportData, - mode, + flow, + header, selectedPeriodId, selectedGroupKey, setSelectedGroupKey, compact = true, + isFetching, }: Props) { const { items, total } = React.useMemo(() => { - return extractTopTags(reportData, mode, selectedPeriodId); - }, [reportData, mode, selectedPeriodId]); + return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey); + }, [reportData, flow, selectedPeriodId, selectedGroupKey]); return ( - - {items.map((item) => { - const isSelected = selectedGroupKey?.tags?.includes(item.tag); - return ( - { - if (setSelectedGroupKey) { - setSelectedGroupKey(isSelected ? null : { tags: [item.tag] }); - } - }} - /> - ); - })} - + + {header} + + + + {items.map((item) => { + const isSelected = selectedGroupKey?.tags?.includes(item.tag); + return ( + { + if (setSelectedGroupKey) { + let newKey = selectedGroupKey ? { ...selectedGroupKey } : {}; + + if (isSelected) { + delete newKey.tags; + } else { + newKey.tags = [item.tag]; + } + + setSelectedGroupKey(Object.keys(newKey).length ? newKey : null); + } + }} + /> + ); + })} + + ); } diff --git a/src/components/report.helpers.ts b/src/components/report.helpers.ts index b77c3ac..0b21725 100644 --- a/src/components/report.helpers.ts +++ b/src/components/report.helpers.ts @@ -2,11 +2,12 @@ import { ReportPeriod, ReportBucket, GroupKey, + PeriodType, } from "../features/report"; // ─── Types ──────────────────────────────────────────────────── -export type PeriodKey = "weekly" | "monthly" | "yearly" | "fyly" | "full"; +export type PeriodKey = PeriodType; export type DecoratedPeriod = ReportPeriod & { id: string; @@ -16,11 +17,10 @@ export type DecoratedPeriod = ReportPeriod & { // ─── Period helpers ─────────────────────────────────────────── const PREFIX_TO_KEY: Record = { + D: "daily", W: "weekly", M: "monthly", - Y: "yearly", - FY: "fyly", - FULL: "full", + ALL: "all", }; /** @@ -29,19 +29,16 @@ const PREFIX_TO_KEY: Record = { */ export function periodIdToKey(periodId: string): PeriodKey { const prefix = periodId.split(":")[0]; - return PREFIX_TO_KEY[prefix] ?? "full"; + return PREFIX_TO_KEY[prefix] ?? "all"; } // ─── Metric helpers ─────────────────────────────────────────── -export function getAmount( - period: ReportPeriod, - mode: "expense" | "income" -): number { - return mode === "expense" ? period.expenses.sum : period.incomes.sum; +export function getAmount(period: ReportPeriod): number { + return period.metric.sum; } -function mergeMetric(a: ReportPeriod["expenses"], b: ReportPeriod["expenses"]) { +function mergeMetric(a: ReportPeriod["metric"], b: ReportPeriod["metric"]) { const sum = a.sum + b.sum; const count = a.count + b.count; @@ -78,14 +75,12 @@ export function mergeBucketPeriods( if (!existing) { map.set(p.id, { ...p, - expenses: { ...p.expenses }, - incomes: { ...p.incomes }, + metric: { ...p.metric }, }); } else { map.set(p.id, { ...existing, - expenses: mergeMetric(existing.expenses, p.expenses), - incomes: mergeMetric(existing.incomes, p.incomes), + metric: mergeMetric(existing.metric, p.metric), }); } } @@ -126,7 +121,7 @@ export function matchesGroupKey( selected: GroupKey ): boolean { for (const [dim, values] of Object.entries(selected)) { - const bucketValues = bucket.group_key[dim as keyof GroupKey]; + const bucketValues = bucket.group_key[dim]; if (!bucketValues) return false; if (!(values as string[]).every((v) => bucketValues.includes(v))) return false; diff --git a/src/dashboard-config.ts b/src/dashboard-config.ts index 868716e..8f2b7f6 100644 --- a/src/dashboard-config.ts +++ b/src/dashboard-config.ts @@ -2,6 +2,7 @@ import HistoryChart from "./components/HistoryChart"; import LatestItems from "./components/LatestItems"; import { DashboardConfig } from "./components/Dashboard"; import TopTags from "./components/ProgressCard/TopTags"; +import TopPayees from "./components/ProgressCard/TopPayees"; export const configuration: DashboardConfig = { sections: [ @@ -12,7 +13,6 @@ export const configuration: DashboardConfig = { component: HistoryChart, settings: { tabs: ["Weekly", "Monthly"], - // tabs: ["Weekly", "Monthly", "Yearly", "Financial Year", "All Time"], }, style: { size: 12, @@ -29,8 +29,20 @@ export const configuration: DashboardConfig = { size: 12, }, }, + { + id: "top-payees", + title: 'Top Payees', + component: TopPayees, + settings: { + compact: true, + }, + style: { + size: 12, + }, + }, { id: "items", + title: 'Recent Transactions', component: LatestItems, style: { size: 12, @@ -39,7 +51,7 @@ export const configuration: DashboardConfig = { ], style: { palette: { - expense: { + outflows: { light: { primary: "#d32f2f", background: "#fdecea", @@ -51,7 +63,7 @@ export const configuration: DashboardConfig = { text: "#ffcdd2" } }, - income: { + inflows: { light: { primary: "#2e7d32", background: "#e8f5e9", diff --git a/src/features/report/index.ts b/src/features/report/index.ts index 69e51e3..851610e 100644 --- a/src/features/report/index.ts +++ b/src/features/report/index.ts @@ -6,7 +6,9 @@ export type { ReportData, ReportBucket, ReportPeriod, + ReportQuery, GroupKey, + PeriodType, } from './report.models' export { prepareReport diff --git a/src/features/report/report.models.ts b/src/features/report/report.models.ts index e393fdb..4f3ee20 100644 --- a/src/features/report/report.models.ts +++ b/src/features/report/report.models.ts @@ -1,29 +1,40 @@ export interface Payor { + id?: string; name: string; + username: string; + email: string; } export interface Payee { + type: "merchant" | "person" | "transfer" | "other"; name: string; } export interface Account { + id: string; name: string; number: string; + type: "cash" | "bank" | "credit_card" | "wallet" | "other"; + currency: string; + is_active?: boolean; } export interface Tag { + id: string; name: string; icon: string; - description: string; + parent_id?: string | null; } export interface Transaction { + id: string; payor: Payor; payee: Payee; amount: number; account: Account; tags: Tag[]; - occurred_at: Date; + occurred_at: string; + created_at: string; } // ----------------------------- @@ -41,12 +52,12 @@ export interface ReportMetric { // Period // ----------------------------- -export interface ReportPeriod { - start: Date; - end: Date; +export type PeriodType = "daily" | "weekly" | "monthly" | "all"; - expenses: ReportMetric; - incomes: ReportMetric; +export interface ReportPeriod { + start: string; + end: string; + metric: ReportMetric; } // ----------------------------- @@ -54,46 +65,48 @@ export interface ReportPeriod { // ----------------------------- export type GroupKey = { - payee?: string[]; - tags?: string[]; - flow?: string[]; + [dimension: string]: string[]; }; export interface ReportBucket { group_key: GroupKey; periods: { + daily?: ReportPeriod[]; weekly?: ReportPeriod[]; monthly?: ReportPeriod[]; - yearly?: ReportPeriod[]; - fyly?: ReportPeriod[]; - full?: ReportPeriod[]; + all?: ReportPeriod[]; }; } +// ----------------------------- +// Report Query +// ----------------------------- + +export interface ReportQuery { + accounts?: string[] | null; + ignore_self?: boolean | null; + start_date?: string | null; + end_date?: string | null; + min_amount?: number | null; + max_amount?: number | null; +} + // ----------------------------- // Final Report // ----------------------------- export interface ReportData { - periods: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[]; + snapshot_id?: string | null; - rolling: boolean; - report_date?: string; + flow?: "inflows" | "outflows" | null; - group_by: ("payee" | "tags")[]; + periods: PeriodType[]; - ignore_self: boolean; - include_transactions: boolean; - - start_date?: string | null; - end_date?: string | null; - flow?: "expense" | "income" | null; - payee?: string[] | null; - account?: string[] | null; tags?: string[] | null; - min_amount?: number | null; - max_amount?: number | null; + payee?: string[] | null; buckets: ReportBucket[]; + + query: ReportQuery; } diff --git a/src/features/report/report.utils.ts b/src/features/report/report.utils.ts index 10e4b9a..81dd2a5 100644 --- a/src/features/report/report.utils.ts +++ b/src/features/report/report.utils.ts @@ -1,6 +1,7 @@ import { ReportData, - ReportPeriod + ReportPeriod, + PeriodType, } from "./report.models"; /* ---------- ID BUILDING ---------- */ @@ -13,7 +14,7 @@ function formatDate(d: Date): string { } function buildPeriodId( - type: "weekly" | "monthly" | "yearly" | "fyly" | "full", + type: PeriodType, start: Date, end: Date ): string { @@ -21,16 +22,14 @@ function buildPeriodId( const e = formatDate(end); switch (type) { + case "daily": + return `D:${s}_${e}`; case "weekly": return `W:${s}_${e}`; case "monthly": return `M:${s}_${e}`; - case "yearly": - return `Y:${s}_${e}`; - case "fyly": - return `FY:${s}_${e}`; - case "full": - return `FULL:${s}_${e}`; + case "all": + return `ALL:${s}_${e}`; default: return `${s}_${e}`; } @@ -60,19 +59,15 @@ const yearFmt = new Intl.DateTimeFormat("en-GB", { timeZone: "UTC", }); -function sameMonth(a: Date, b: Date) { - return ( - a.getUTCFullYear() === b.getUTCFullYear() && - a.getUTCMonth() === b.getUTCMonth() - ); -} - function buildLabel( - type: "weekly" | "monthly" | "yearly" | "fyly" | "full", + type: PeriodType, start: Date, end: Date ): string { switch (type) { + case "daily": + return dayFmt.format(start); + case "weekly": { const sDay = start.getUTCDate(); const m = monthFmt.format(start); @@ -82,15 +77,6 @@ function buildLabel( case "monthly": return `${monthFmt.format(start)} ${yearFmt.format(start)}`; - case "yearly": - return yearFmt.format(start); - - case "fyly": { - const startY = start.getUTCFullYear(); - const endY = end.getUTCFullYear(); - return `FY ${startY}–${String(endY).slice(-2)}`; - } - default: return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`; } @@ -99,7 +85,7 @@ function buildLabel( /* ---------- MAIN ---------- */ function decoratePeriods( - type: "weekly" | "monthly" | "yearly" | "fyly" | "full", + type: PeriodType, periods: ReportPeriod[] ): (ReportPeriod & { id: string; label: string })[] { return periods.map((p) => ({ diff --git a/src/features/report/useReport.ts b/src/features/report/useReport.ts index 3c8ec74..1ffea4d 100644 --- a/src/features/report/useReport.ts +++ b/src/features/report/useReport.ts @@ -1,20 +1,11 @@ import { useResourceByName } from "../../../react-openapi"; export interface ReportParams { - periods?: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[]; - rolling?: boolean; - report_date?: string; - group_by?: ("payee" | "tags")[]; - ignore_self?: boolean; - include_transactions?: boolean; - start_date?: string; - end_date?: string; - flow?: "expense" | "income"; + snapshot_id?: string; + periods?: ("daily" | "weekly" | "monthly" | "all")[]; + flow?: "inflows" | "outflows"; payee?: string[]; - account?: string[]; tags?: string[]; - min_amount?: number; - max_amount?: number; } export function useReport(params: ReportParams) { @@ -23,6 +14,5 @@ export function useReport(params: ReportParams) { return useList({ ...params, periods: params.periods, - group_by: params.group_by, }); }