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:
@@ -6,7 +6,9 @@ export type {
|
||||
ReportData,
|
||||
ReportBucket,
|
||||
ReportPeriod,
|
||||
ReportQuery,
|
||||
GroupKey,
|
||||
PeriodType,
|
||||
} from './report.models'
|
||||
export {
|
||||
prepareReport
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user