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:
2026-05-18 05:37:51 +00:00
committed by aetos
parent ad62d7dd9c
commit 8bea3d06f6
25 changed files with 478 additions and 251 deletions

View File

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

View File

@@ -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;
}

View File

@@ -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) => ({

View File

@@ -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,
});
}