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

@@ -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<DashboardMode, ThemeAwarePalette>;
palette?: Record<DashboardFlow, ThemeAwarePalette>;
};
}
export interface DashboardProps {
config: DashboardConfig;
data: ReportData;
isFetching?: boolean;
onFlowChange?: (state: DashboardState) => void;
}

View File

@@ -4,18 +4,27 @@ import { DashboardProps, DashboardState } from "./Dashboard.models";
export default function Dashboard(props: DashboardProps) {
const [state, setState] = React.useState<DashboardState>({
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<HTMLElement>,
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}

View File

@@ -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<React.SetStateAction<DashboardState>>;
toggleMode: () => void;
toggleFlow: (event: React.MouseEvent<HTMLElement>, 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 (
<Container
@@ -69,11 +70,11 @@ export default function DashboardView({
transition: 'background 0.3s ease'
}}
>
<Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}>
<Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", mb: 3 }}>
<ToggleButtonGroup
value={mode}
value={flow}
exclusive
onChange={toggleMode}
onChange={toggleFlow}
sx={{
borderRadius: 3,
overflow: "hidden",
@@ -89,9 +90,19 @@ export default function DashboardView({
},
}}
>
<ToggleButton value="expense">Expenses</ToggleButton>
<ToggleButton value="income">Income</ToggleButton>
<ToggleButton value="outflows">Outflows</ToggleButton>
<ToggleButton value="inflows">Inflows</ToggleButton>
</ToggleButtonGroup>
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
<Button
size="small"
sx={{ mt: 1, textTransform: "none" }}
onClick={() => setSelectedGroupKey(null)}
>
Clear Drill-down
</Button>
)}
</Box>
<Grid container spacing={4}>
@@ -100,14 +111,6 @@ export default function DashboardView({
return (
<Grid key={section.id} size={section.style?.size || 12 as any}>
{section.title && !section.isList && (
<Box sx={{ mb: 2 }}>
<Typography variant="h6" fontWeight={700}>
{section.title}
</Typography>
</Box>
)}
<Component
{...section.settings}
header={section.title}
@@ -118,7 +121,7 @@ export default function DashboardView({
colorScheme={colors}
// State management
mode={mode}
flow={flow}
periodType={periodType}
comparison={comparison}
@@ -129,6 +132,7 @@ export default function DashboardView({
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
isFetching={arguments[0].isFetching}
/>
</Grid>
);

View File

@@ -9,15 +9,14 @@ import { ChartDataPoint } from "./HistoryChart.models";
// ─── Tab → PeriodKey ─────────────────────────────────────────
const TAB_TO_KEY: Record<string, PeriodKey> = {
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) {

View File

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

View File

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

View File

@@ -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",
}}
>
<Typography variant="h6" fontWeight={700} gutterBottom>

View File

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

View File

@@ -8,7 +8,9 @@ export interface LatestItem {
export interface LatestItemsViewProps {
items: LatestItem[];
header: string;
accentColor: string;
canExpand: boolean;
onExpand: () => void;
isFetching?: boolean;
}

View File

@@ -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 (
<LatestItemsView
items={visibleItems}
header={header}
accentColor={accentColor}
canExpand={canExpand}
isFetching={isFetching}
onExpand={() => setVisibleCount((prev) => prev + 5)}
/>
);

View File

@@ -14,15 +14,17 @@ import { LatestItemsViewProps } from "./LatestItems.models";
export default function LatestItemsView({
items,
header,
accentColor,
canExpand,
onExpand,
isFetching,
}: LatestItemsViewProps) {
return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2, opacity: isFetching ? 0.6 : 1, transition: "opacity 0.3s ease", pointerEvents: isFetching ? "none" : "auto" }}>
<Box sx={{ mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold">
Recent Transactions
{header}
</Typography>
</Box>

View File

@@ -7,4 +7,5 @@ export interface ProgressCardProps {
compact?: boolean;
selected?: boolean;
onClick?: () => void;
isFetching?: boolean;
}

View File

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

View 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,
};
}

View 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>
);
}

View File

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

View File

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

View File

@@ -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<string, PeriodKey> = {
D: "daily",
W: "weekly",
M: "monthly",
Y: "yearly",
FY: "fyly",
FULL: "full",
ALL: "all",
};
/**
@@ -29,19 +29,16 @@ const PREFIX_TO_KEY: Record<string, PeriodKey> = {
*/
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;