Dashboard Refactor: Flow-based Metrics + Unified Data Model (#4)
# 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: #4
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
This commit is contained in:
@@ -7,4 +7,5 @@ export interface ProgressCardProps {
|
||||
compact?: boolean;
|
||||
selected?: boolean;
|
||||
onClick?: () => void;
|
||||
isFetching?: boolean;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
<Typography
|
||||
|
||||
65
src/components/ProgressCard/TopPayees.adapter.ts
Normal file
65
src/components/ProgressCard/TopPayees.adapter.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { mergeBucketPeriods, periodIdToKey } from "../report.helpers";
|
||||
import { GroupKey, ReportData } from "../../features/report";
|
||||
|
||||
export interface PayeeItem {
|
||||
name: string;
|
||||
amount: number;
|
||||
}
|
||||
|
||||
export function extractTopPayees(
|
||||
reportData: ReportData,
|
||||
flow: "outflows" | "inflows",
|
||||
selectedPeriodId?: string | null,
|
||||
selectedGroupKey?: GroupKey | null
|
||||
): { items: PayeeItem[]; total: number } {
|
||||
const payeeMap = new Map<string, number>();
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
93
src/components/ProgressCard/TopPayees.tsx
Normal file
93
src/components/ProgressCard/TopPayees.tsx
Normal file
@@ -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 (
|
||||
<Paper
|
||||
sx={{
|
||||
p: { xs: 2.5, sm: 4 },
|
||||
borderRadius: 4,
|
||||
width: "100%",
|
||||
boxShadow: "none",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
opacity: isFetching ? 0.6 : 1,
|
||||
transition: "opacity 0.3s ease",
|
||||
pointerEvents: isFetching ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" fontWeight={700} gutterBottom>
|
||||
{header}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
md: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = selectedGroupKey?.payee?.includes(item.name);
|
||||
return (
|
||||
<ProgressCard
|
||||
key={item.name}
|
||||
header={item.name}
|
||||
progressAmount={item.amount}
|
||||
totalAmount={total}
|
||||
compact={compact}
|
||||
colorTheme={flow === "outflows" ? "error" : "success"}
|
||||
selected={isSelected}
|
||||
isFetching={isFetching}
|
||||
onClick={() => {
|
||||
if (setSelectedGroupKey) {
|
||||
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
|
||||
|
||||
if (isSelected) {
|
||||
delete newKey.payee;
|
||||
} else {
|
||||
newKey.payee = [item.name];
|
||||
}
|
||||
|
||||
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -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<string, number>();
|
||||
|
||||
for (const bucket of reportData.buckets) {
|
||||
const tags = bucket.group_key.tags;
|
||||
if (!tags || tags.length === 0) continue;
|
||||
let periodKey: ReturnType<typeof periodIdToKey> = "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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Box
|
||||
<Paper
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
md: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
p: { xs: 2.5, sm: 4 },
|
||||
borderRadius: 4,
|
||||
width: "100%",
|
||||
boxShadow: "none",
|
||||
border: "1px solid",
|
||||
borderColor: "divider",
|
||||
bgcolor: "background.paper",
|
||||
opacity: isFetching ? 0.6 : 1,
|
||||
transition: "opacity 0.3s ease",
|
||||
pointerEvents: isFetching ? "none" : "auto",
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = selectedGroupKey?.tags?.includes(item.tag);
|
||||
return (
|
||||
<ProgressCard
|
||||
key={item.tag}
|
||||
header={item.tag}
|
||||
progressAmount={item.amount}
|
||||
totalAmount={total}
|
||||
compact={compact}
|
||||
colorTheme={mode === "expense" ? "error" : "success"}
|
||||
selected={isSelected}
|
||||
onClick={() => {
|
||||
if (setSelectedGroupKey) {
|
||||
setSelectedGroupKey(isSelected ? null : { tags: [item.tag] });
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
<Typography variant="h6" fontWeight={700} gutterBottom>
|
||||
{header}
|
||||
</Typography>
|
||||
|
||||
<Box
|
||||
sx={{
|
||||
display: "grid",
|
||||
gridTemplateColumns: {
|
||||
xs: "1fr",
|
||||
sm: "repeat(2, 1fr)",
|
||||
md: "repeat(4, 1fr)",
|
||||
},
|
||||
gap: 2,
|
||||
}}
|
||||
>
|
||||
{items.map((item) => {
|
||||
const isSelected = selectedGroupKey?.tags?.includes(item.tag);
|
||||
return (
|
||||
<ProgressCard
|
||||
key={item.tag}
|
||||
header={item.tag}
|
||||
progressAmount={item.amount}
|
||||
totalAmount={total}
|
||||
compact={compact}
|
||||
colorTheme={flow === "outflows" ? "error" : "success"}
|
||||
selected={isSelected}
|
||||
isFetching={isFetching}
|
||||
onClick={() => {
|
||||
if (setSelectedGroupKey) {
|
||||
let newKey = selectedGroupKey ? { ...selectedGroupKey } : {};
|
||||
|
||||
if (isSelected) {
|
||||
delete newKey.tags;
|
||||
} else {
|
||||
newKey.tags = [item.tag];
|
||||
}
|
||||
|
||||
setSelectedGroupKey(Object.keys(newKey).length ? newKey : null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Box>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user