15 Commits

Author SHA1 Message Date
ce9d0961df correct color component 2026-05-18 11:05:00 +05:30
9fe2ed8c5c to payee filtering and updated drill down logic both ways 2026-05-18 11:03:13 +05:30
2286d9b860 to payee 2026-05-18 08:34:18 +05:30
ccfb597342 minor configration cleanups 2026-05-18 08:14:14 +05:30
fe94249b02 fixed setState error 2026-05-18 08:01:08 +05:30
fff304ad1e removed expense and income vs outflow and inflow 2026-05-18 07:58:35 +05:30
58271584ce txn filtering based on tags 2026-05-18 07:44:04 +05:30
000c0063e5 soft reload 2026-05-18 07:38:22 +05:30
d66406ba86 cleanup 2026-05-17 19:57:44 +05:30
32303f7067 top category working 2026-05-17 19:53:08 +05:30
13f091a82c ui fixes for snapshot report 2026-05-17 19:14:45 +05:30
ad62d7dd9c filter-by-payee-and-tags (#3)
Reviewed-on: #3
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-05-12 06:24:47 +00:00
77b60ba073 items-by-period (#2)
Reviewed-on: #2
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-05-09 13:00:42 +00:00
f213a9455b Fix pagination bounds in HistoryChart and add responsive grid to TopTags 2026-05-07 16:42:52 +05:30
009ab50b47 major refactor of the dashboard and react-openapi integration (#1)
## Summary

This MR introduces a major refactor of the dashboard and react-openapi integration, focusing on configurability, separation of concerns, and improved extensibility.

---

## Key Changes

### 1. OpenAPI / Admin Refactor

* Extracted `ConfigContext` into a dedicated provider.
* Introduced `AppProvider` to encapsulate:

  * Config loading
  * API client initialization
  * React Query setup
* Removed internal `QueryClientProvider` from `Admin` for better composability.
* `Admin` now supports both:

  * Standalone usage
  * Nested usage inside an existing provider

### 2. Resource System Improvements

* Added `hidden` flag to `ResourceConfig` and overrides.
* Admin UI now filters out hidden resources.
* Added `useResourceByName` helper for dynamic resource access.
* Improved `useResource`:

  * Handles undefined config safely
  * Adds guards for missing endpoints
  * Disables queries when endpoint is absent

### 3. API Client Enhancements

* Added custom `paramsSerializer`:

  * Serializes arrays without `[]`
  * Ensures backend-compatible query formats

### 4. Dashboard Architecture Overhaul

* Replaced hardcoded dashboard with **config-driven system**:

  * Introduced `ConfigurableDashboard`
  * Dashboard sections defined via config
* New state model:

  * `mode` (expense/income)
  * `periodType` (rolling/calendar)
  * `comparison`
  * `selectedPeriodId`

### 5. Component Refactor (View / Logic Split)

* Split major components into:

  * `.tsx` (logic/controller)
  * `.view.tsx` (presentation)
  * `.models.ts` (types)
* Applied to:

  * Dashboard
  * HistoryChart
  * ProgressCard
  * LatestItems

### 6. HistoryChart Redesign

* Fully rebuilt using report-driven data
* Supports:

  * Weekly / Monthly / Yearly / FY / Full views
  * Rolling vs Calendar periods
  * Comparison mode (auto-aligned offsets)
* Introduced:

  * Bucket merging logic
  * Dynamic comparison attachment

### 7. Reporting Integration

* Dashboard now powered by:

  * `useReport`
  * `prepareReport`
* Removes need for multiple manual API calls

### 8. UI / UX Improvements

* Theme-aware color system
* Dynamic accent colors per mode
* Cleaner layout using section-based rendering
* Improved selection and interaction in charts

### 9. Cleanup & Removals

* Removed legacy components:

  * Old `HistoryChart`
  * Old `ProgressCard`
* Simplified Header layout spacing

---

## Behavior Changes

* Hidden resources are no longer visible in Admin UI.
* Dashboard is now entirely configuration-driven.
* API query params for arrays no longer use `[]`.
* Resource hooks no longer crash on missing config.

---

## Risks / Considerations

* Dashboard depends on correct configuration structure.
* Hidden flag may unintentionally hide resources if misconfigured.
* Query param serialization change must align with backend expectations.

---

## Follow-ups

* Add typing improvements to remove `@ts-ignore` in `GenericForm`.
* Extend dashboard config with more reusable section presets.
* Add tests for report aggregation and comparison logic.

---

Reviewed-on: #1
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-05-07 11:00:54 +00:00
27 changed files with 1068 additions and 478 deletions

View File

@@ -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 { api } from "../api/client";
import { ResourceConfig } from "../types/config"; import { ResourceConfig } from "../types/config";
import { ConfigContext } from "../providers/ConfigContext"; import { ConfigContext } from "../providers/ConfigContext";
@@ -26,6 +26,7 @@ export function useResource<T = any>(config: ResourceConfig | undefined) {
}; };
}, },
enabled: !!endpoint, enabled: !!endpoint,
placeholderData: keepPreviousData,
}); });
// --- READ ONE --- // --- READ ONE ---

View File

@@ -3,10 +3,15 @@ import {
Box, Box,
Container, Container,
CircularProgress, CircularProgress,
Alert Alert,
TextField,
Paper,
Autocomplete,
Button
} from "@mui/material"; } from "@mui/material";
import ConfigurableDashboard from "./components/Dashboard"; import ConfigurableDashboard from "./components/Dashboard";
import { DashboardState } from "./components/Dashboard";
import { configuration } from "./dashboard-config"; import { configuration } from "./dashboard-config";
import { import {
useReport, useReport,
@@ -14,18 +19,65 @@ import {
} from "./features/report"; } from "./features/report";
export default function Dashboard() { export default function Dashboard() {
const [flow, setFlow] = React.useState<"outflows" | "inflows">("outflows");
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
const [payeeInput, setPayeeInput] = React.useState<string[]>([]);
const [tagsInput, setTagsInput] = React.useState<string[]>([]);
const [loadedPayees, setLoadedPayees] = React.useState<string[]>([]);
const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
const report = useReport({ const report = useReport({
periods: ["weekly", "monthly", "full"], periods: ["daily", "weekly", "monthly", "all"],
rolling: true, flow: flow,
include_transactions: true, payee: appliedPayees.length > 0 ? appliedPayees : undefined,
group_by: ["tags"], tags: appliedTags.length > 0 ? appliedTags : undefined,
}) });
React.useEffect(() => {
if (report.data?.data) {
setLoadedPayees(prev => {
const pSet = new Set<string>(prev);
report.data.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
if (t.payee?.name) pSet.add(t.payee.name);
});
});
});
});
return Array.from(pSet).sort();
});
setLoadedTags(prev => {
const tSet = new Set<string>(prev);
report.data.data.buckets.forEach((b: any) => {
Object.values(b.periods).forEach((periodArray: any) => {
periodArray?.forEach((p: any) => {
p.metric?.transactions?.forEach((t: any) => {
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
});
});
});
});
return Array.from(tSet).sort();
});
}
}, [report.data?.data]);
const isLoading = report.isLoading; const isLoading = report.isLoading;
const error = report.error; const error = report.error;
/** Callback for the ConfigurableDashboard's flow toggle */
const handleFlowChange = React.useCallback((newState: DashboardState) => {
setFlow(newState.flow);
}, []);
if (isLoading) { if (isLoading && !report.data) {
return ( return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}> <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress /> <CircularProgress />
@@ -41,15 +93,76 @@ export default function Dashboard() {
); );
} }
if (!report) { if (!report.data) {
return null; return null;
} }
const data = prepareReport(report.data?.data); const data = prepareReport(report.data.data);
return ( return (
<ConfigurableDashboard <Box>
config={configuration} <Container>
data={data} <Paper
/> sx={{
mt: 4,
p: 2,
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 2,
alignItems: { xs: "stretch", sm: "flex-end" },
borderRadius: 4,
mb: -2 // pull up to be closer to the dashboard container below
}}
elevation={0}
variant="outlined"
>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Payee
</Box>
<Autocomplete
multiple
freeSolo
options={loadedPayees}
value={payeeInput}
onChange={(_, val) => setPayeeInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add payees..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minWidth: { sm: 250 } }}>
<Box sx={{ typography: 'caption', mb: 1, color: 'text.secondary' }}>
Filter by Tags
</Box>
<Autocomplete
multiple
freeSolo
options={loadedTags}
value={tagsInput}
onChange={(_, val) => setTagsInput(val as string[])}
renderInput={(params) => <TextField {...params} placeholder="Add tags..." />}
sx={{ '& .MuiOutlinedInput-root': { height: 'auto', minHeight: '2.5rem', py: 0.5 } }}
/>
</Box>
<Button
variant="contained"
size="large"
onClick={() => {
setAppliedPayees(payeeInput);
setAppliedTags(tagsInput);
}}
disabled={isLoading}
sx={{ height: 40, borderRadius: 2 }}
>
Apply
</Button>
</Paper>
</Container>
<ConfigurableDashboard
config={configuration}
data={data}
isFetching={report.isFetching}
onFlowChange={handleFlowChange}
/>
</Box>
); );
} }

View File

@@ -1,16 +1,18 @@
import * as React from "react"; import * as React from "react";
import { import {
ReportData ReportData,
GroupKey,
} from "../../features/report"; } from "../../features/report";
export type DashboardMode = "expense" | "income"; export type DashboardFlow = "outflows" | "inflows";
export type DashboardPeriodType = "rolling" | "calendar"; export type DashboardPeriodType = "rolling" | "calendar";
export type DashboardSelectedPeriodId = string | null; export type DashboardSelectedPeriodId = string | null;
export interface DashboardState { export interface DashboardState {
mode: DashboardMode; flow: DashboardFlow;
periodType: DashboardPeriodType; periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId; selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean; comparison: boolean;
} }
@@ -41,11 +43,13 @@ export interface ThemeAwarePalette {
export interface DashboardConfig { export interface DashboardConfig {
sections: DashboardSection[]; sections: DashboardSection[];
style?: { style?: {
palette?: Record<DashboardMode, ThemeAwarePalette>; palette?: Record<DashboardFlow, ThemeAwarePalette>;
}; };
} }
export interface DashboardProps { export interface DashboardProps {
config: DashboardConfig; config: DashboardConfig;
data: ReportData; data: ReportData;
isFetching?: boolean;
onFlowChange?: (state: DashboardState) => void;
} }

View File

@@ -4,17 +4,27 @@ import { DashboardProps, DashboardState } from "./Dashboard.models";
export default function Dashboard(props: DashboardProps) { export default function Dashboard(props: DashboardProps) {
const [state, setState] = React.useState<DashboardState>({ const [state, setState] = React.useState<DashboardState>({
mode: "expense", flow: "outflows",
periodType: "rolling", periodType: "rolling",
selectedPeriodId: null, selectedPeriodId: null,
selectedGroupKey: null,
comparison: false, comparison: false,
}); });
const toggleMode = () => { const toggleFlow = (
setState(prev => ({ event: React.MouseEvent<HTMLElement>,
...prev, newFlow: "outflows" | "inflows" | null
mode: prev.mode === "expense" ? "income" : "expense", ) => {
})); if (newFlow === null) return;
setState(prev => {
if (prev.flow === newFlow) return prev;
const next = { ...prev, flow: newFlow };
props.onFlowChange?.(next);
return next;
});
}; };
const togglePeriodType = () => { const togglePeriodType = () => {
@@ -35,15 +45,20 @@ export default function Dashboard(props: DashboardProps) {
setState(prev => ({ ...prev, selectedPeriodId })); setState(prev => ({ ...prev, selectedPeriodId }));
}; };
const setSelectedGroupKey = (groupKey: typeof state.selectedGroupKey) => {
setState(prev => ({ ...prev, selectedGroupKey: groupKey }));
};
return ( return (
<DashboardView <DashboardView
{...props} {...props}
state={state} state={state}
setState={setState} setState={setState}
toggleMode={toggleMode} toggleFlow={toggleFlow}
togglePeriodType={togglePeriodType} togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison} toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId} setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
/> />
); );
} }

View File

@@ -5,17 +5,20 @@ import {
Grid, Grid,
Typography, Typography,
ToggleButton, ToggleButton,
ToggleButtonGroup ToggleButtonGroup,
Button
} from "@mui/material"; } from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles"; import { useTheme, alpha } from "@mui/material/styles";
import { GroupKey } from "../../features/report";
import { DashboardProps, DashboardState } from "./Dashboard.models"; import { DashboardProps, DashboardState } from "./Dashboard.models";
interface ViewProps extends DashboardProps { interface ViewProps extends DashboardProps {
state: DashboardState; state: DashboardState;
setState: React.Dispatch<React.SetStateAction<DashboardState>>; setState: React.Dispatch<React.SetStateAction<DashboardState>>;
toggleMode: () => void; toggleFlow: (event: React.MouseEvent<HTMLElement>, newFlow: "outflows" | "inflows" | null) => void;
togglePeriodType: () => void; togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void; setSelectedPeriodId: (id: string | null) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleComparison: () => void; toggleComparison: () => void;
} }
@@ -24,18 +27,19 @@ export default function DashboardView({
data, data,
state, state,
setState, setState,
toggleMode, toggleFlow,
togglePeriodType, togglePeriodType,
toggleComparison, toggleComparison,
setSelectedPeriodId, setSelectedPeriodId,
setSelectedGroupKey,
}: ViewProps) { }: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const themeMode = theme.palette.mode; const themeMode = theme.palette.mode;
const { mode, periodType, comparison, selectedPeriodId } = state; const { flow, periodType, comparison, selectedPeriodId, selectedGroupKey } = state;
// Resolve colors with fallbacks // Resolve colors with fallbacks
const colors = React.useMemo(() => { const colors = React.useMemo(() => {
const palette = config.style?.palette?.[mode]; const palette = config.style?.palette?.[flow];
const modeColors = palette ? palette[themeMode] : null; const modeColors = palette ? palette[themeMode] : null;
if (modeColors) { if (modeColors) {
@@ -47,13 +51,13 @@ export default function DashboardView({
} }
// Fallback to standard theme colors // 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 { return {
primary: themeColor.main, primary: themeColor.main,
light: alpha(themeColor.main, themeMode === 'light' ? 0.08 : 0.15), light: alpha(themeColor.main, themeMode === 'light' ? 0.08 : 0.15),
text: themeColor.main text: themeColor.main
}; };
}, [config.style?.palette, mode, themeMode, theme.palette]); }, [config.style?.palette, flow, themeMode, theme.palette]);
return ( return (
<Container <Container
@@ -66,11 +70,11 @@ export default function DashboardView({
transition: 'background 0.3s ease' transition: 'background 0.3s ease'
}} }}
> >
<Box sx={{ display: "flex", justifyContent: "center", mb: 3 }}> <Box sx={{ display: "flex", flexDirection: "column", alignItems: "center", mb: 3 }}>
<ToggleButtonGroup <ToggleButtonGroup
value={mode} value={flow}
exclusive exclusive
onChange={toggleMode} onChange={toggleFlow}
sx={{ sx={{
borderRadius: 3, borderRadius: 3,
overflow: "hidden", overflow: "hidden",
@@ -86,9 +90,19 @@ export default function DashboardView({
}, },
}} }}
> >
<ToggleButton value="expense">Expenses</ToggleButton> <ToggleButton value="outflows">Outflows</ToggleButton>
<ToggleButton value="income">Income</ToggleButton> <ToggleButton value="inflows">Inflows</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{selectedGroupKey && Object.keys(selectedGroupKey).length > 0 && (
<Button
size="small"
sx={{ mt: 1, textTransform: "none" }}
onClick={() => setSelectedGroupKey(null)}
>
Clear Drill-down
</Button>
)}
</Box> </Box>
<Grid container spacing={4}> <Grid container spacing={4}>
@@ -97,14 +111,6 @@ export default function DashboardView({
return ( return (
<Grid key={section.id} size={section.style?.size || 12 as any}> <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 <Component
{...section.settings} {...section.settings}
header={section.title} header={section.title}
@@ -115,15 +121,18 @@ export default function DashboardView({
colorScheme={colors} colorScheme={colors}
// State management // State management
mode={mode} flow={flow}
periodType={periodType} periodType={periodType}
comparison={comparison} comparison={comparison}
selectedPeriodId={selectedPeriodId} selectedPeriodId={selectedPeriodId}
selectedGroupKey={selectedGroupKey}
togglePeriodType={togglePeriodType} togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison} toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId} setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
isFetching={arguments[0].isFetching}
/> />
</Grid> </Grid>
); );

View File

@@ -0,0 +1,73 @@
import { ReportData } from "../../features/report";
import {
mergeBucketPeriods,
getAmount,
PeriodKey,
} from "../report.helpers";
import { ChartDataPoint } from "./HistoryChart.models";
// ─── Tab → PeriodKey ─────────────────────────────────────────
const TAB_TO_KEY: Record<string, PeriodKey> = {
Daily: "daily",
Weekly: "weekly",
Monthly: "monthly",
"All Time": "all",
};
export function tabToKey(tab: string): PeriodKey {
return TAB_TO_KEY[tab] ?? "all";
}
// ─── Comparison ──────────────────────────────────────────────
function attachComparison(
points: ChartDataPoint[],
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;
return -1;
};
return points.map((p, i) => {
const ci = getCompareIndex(i);
return {
...p,
compare:
ci >= 0 && points[ci]
? {
id: points[ci].id,
label: points[ci].label,
amount: points[ci].amount,
}
: undefined,
};
});
}
// ─── Main adapter ────────────────────────────────────────────
export function buildChartData(
reportData: ReportData,
key: PeriodKey,
flow: "outflows" | "inflows",
comparison: boolean
): ChartDataPoint[] {
const merged = mergeBucketPeriods(reportData.buckets, key);
let points: ChartDataPoint[] = merged.map((p) => ({
id: p.id,
label: p.label,
amount: getAmount(p),
}));
if (comparison) {
points = attachComparison(points, key);
}
return points;
}

View File

@@ -1,5 +1,5 @@
import { import {
DashboardMode, DashboardFlow,
DashboardPeriodType, DashboardPeriodType,
DashboardSelectedPeriodId DashboardSelectedPeriodId
} from "../Dashboard"; } from "../Dashboard";
@@ -29,7 +29,7 @@ export interface HistoryChartProps {
text: string; text: string;
}; };
mode: DashboardMode; flow: DashboardFlow;
periodType: DashboardPeriodType; periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId; selectedPeriodId: DashboardSelectedPeriodId;
comparison: boolean; comparison: boolean;
@@ -37,4 +37,6 @@ export interface HistoryChartProps {
togglePeriodType: () => void; togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void; setSelectedPeriodId: (id: string | null) => void;
toggleComparison: () => void; toggleComparison: () => void;
isFetching?: boolean;
} }

View File

@@ -1,133 +1,13 @@
import * as React from "react"; import * as React from "react";
import { HistoryChartProps, ChartDataPoint } from "./HistoryChart.models"; import { HistoryChartProps } from "./HistoryChart.models";
import HistoryChartView from "./HistoryChart.view"; import HistoryChartView from "./HistoryChart.view";
import { ReportPeriod } from "../../features/report"; import { buildChartData, tabToKey } from "./HistoryChart.adapter";
type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
const TAB_TO_KEY: Record<string, "weekly" | "monthly" | "yearly" | "fyly" | "full"> = {
Weekly: "weekly",
Monthly: "monthly",
Yearly: "yearly",
'Financial Year': "fyly",
'All Time': "full"
};
function getAmount(p: ReportPeriod, mode: "expense" | "income") {
return mode === "expense" ? p.expenses.sum : p.incomes.sum;
}
function mergeMetric(a: any, b: any) {
const sum = a.sum + b.sum;
const count = a.count + b.count;
return {
...a,
sum,
count,
average: count > 0 ? sum / count : 0,
transactions: a.transactions || b.transactions
? [
...(a.transactions || []),
...(b.transactions || [])
]
: undefined
};
}
function mergeBuckets(
buckets: any[],
key: "weekly" | "monthly" | "yearly" | "fyly" | "full"
): DecoratedPeriod[] {
const map = new Map<string, DecoratedPeriod>();
for (const bucket of buckets) {
const periods = (bucket.periods[key] || []) as DecoratedPeriod[];
for (const p of periods) {
const existing = map.get(p.id);
if (!existing) {
map.set(p.id, {
...p,
expenses: { ...p.expenses },
incomes: { ...p.incomes }
});
} else {
map.set(p.id, {
...existing,
expenses: mergeMetric(existing.expenses, p.expenses),
incomes: mergeMetric(existing.incomes, p.incomes)
});
}
}
}
return Array.from(map.values()).sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
);
}
function attachComparison(
points: ChartDataPoint[],
key: "weekly" | "monthly" | "yearly" | "fyly" | "full"
): ChartDataPoint[] {
const getCompareIndex = (i: number) => {
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;
};
return points.map((p, i) => {
const ci = getCompareIndex(i);
return {
...p,
compare:
ci >= 0 && points[ci]
? {
id: points[ci].id,
label: points[ci].label,
amount: points[ci].amount
}
: undefined
};
});
}
function buildChartData(
reportData: HistoryChartProps["reportData"],
key: "weekly" | "monthly" | "yearly" | "fyly" | "full",
mode: "expense" | "income",
comparison: boolean
): ChartDataPoint[] {
const merged = mergeBuckets(reportData.buckets, key);
console.log("Merged periods:", merged);
let points: ChartDataPoint[] = merged.map((p) => ({
id: p.id,
label: p.label,
amount: getAmount(p, mode)
}));
if (comparison) {
points = attachComparison(points, key);
}
return points;
}
export default function HistoryChart(props: HistoryChartProps) { export default function HistoryChart(props: HistoryChartProps) {
const { const {
tabs, tabs,
reportData, reportData,
mode, flow,
periodType,
comparison, comparison,
selectedPeriodId, selectedPeriodId,
setSelectedPeriodId setSelectedPeriodId
@@ -136,11 +16,11 @@ export default function HistoryChart(props: HistoryChartProps) {
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || ""); const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
const [startIndex, setStartIndex] = React.useState(0); const [startIndex, setStartIndex] = React.useState(0);
const activeDataKey = TAB_TO_KEY[activeTab]; const activeDataKey = tabToKey(activeTab);
const currentData = React.useMemo(() => { const currentData = React.useMemo(() => {
return buildChartData(reportData, activeDataKey, mode, comparison); return buildChartData(reportData, activeDataKey, flow, comparison);
}, [reportData, activeDataKey, mode, comparison]); }, [reportData, activeDataKey, flow, comparison]);
const maxAmount = const maxAmount =
currentData.length > 0 currentData.length > 0
@@ -155,11 +35,10 @@ export default function HistoryChart(props: HistoryChartProps) {
: 1; : 1;
const visibleCountMap = { const visibleCountMap = {
daily: 7,
weekly: 6, weekly: 6,
monthly: 4, monthly: 4,
yearly: 4, all: 4,
fyly: 4,
full: 4,
}; };
const visibleCount = visibleCountMap[activeDataKey] ?? 4; const visibleCount = visibleCountMap[activeDataKey] ?? 4;
@@ -184,7 +63,7 @@ export default function HistoryChart(props: HistoryChartProps) {
React.useEffect(() => { React.useEffect(() => {
setSelectedPeriodId(null); setSelectedPeriodId(null);
}, [activeTab, periodType]); }, [activeTab]);
React.useEffect(() => { React.useEffect(() => {
if ( if (

View File

@@ -35,7 +35,7 @@ export default function HistoryChartView(props: ViewProps) {
tabs, tabs,
colorScheme, colorScheme,
mode, flow,
periodType, periodType,
selectedPeriodId, selectedPeriodId,
comparison, comparison,
@@ -58,19 +58,28 @@ export default function HistoryChartView(props: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === "dark";
const total = currentData.length;
const maxStartIndex = Math.max(total - visibleCount, 0);
const clampedStartIndex = Math.min(startIndex, maxStartIndex);
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => { const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
if (newTab !== null) setActiveTab(newTab); if (newTab !== null) setActiveTab(newTab);
}; };
const canGoLeft = startIndex > 0; const canGoLeft = clampedStartIndex > 0;
const canGoRight = startIndex + visibleCount < currentData.length; const canGoRight = clampedStartIndex < maxStartIndex;
const handlePrev = () => { const handlePrev = () => {
if (canGoLeft) setStartIndex((prev) => prev - visibleCount); if (!canGoLeft) return;
setStartIndex((prev) => Math.max(prev - visibleCount, 0));
}; };
const handleNext = () => { const handleNext = () => {
if (canGoRight) setStartIndex((prev) => prev + visibleCount); if (!canGoRight) return;
setStartIndex((prev) => {
const next = prev + visibleCount;
return Math.min(next, maxStartIndex);
});
}; };
return ( return (
@@ -83,6 +92,9 @@ export default function HistoryChartView(props: ViewProps) {
border: "1px solid", border: "1px solid",
borderColor: "divider", borderColor: "divider",
bgcolor: isDark ? "background.paper" : colorScheme.light, 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> <Typography variant="h6" fontWeight={700} gutterBottom>

View File

@@ -0,0 +1,79 @@
import { ReportData, Transaction, GroupKey } from "../../features/report";
import {
mergeBucketPeriods,
periodIdToKey,
formatCurrency,
filterBuckets,
} from "../report.helpers";
import { LatestItem } from "./LatestItems.models";
// ─── Transaction extraction ─────────────────────────────────
function extractTransactions(
reportData: ReportData,
selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
): Transaction[] {
// 1. Get raw transactions
let rawTxns: Transaction[] = [];
if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(reportData.buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId);
rawTxns = selected?.metric.transactions || [];
} else {
const periods = mergeBucketPeriods(reportData.buckets, "all");
if (periods.length > 0) {
rawTxns = periods[0].metric.transactions || [];
}
}
// 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;
});
}
return rawTxns;
}
// ─── Main adapter ────────────────────────────────────────────
export function buildLatestItems(
reportData: ReportData,
selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
flow: "outflows" | "inflows"
): LatestItem[] {
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey);
return txns
.sort(
(a, b) =>
new Date(b.occurred_at).getTime() -
new Date(a.occurred_at).getTime()
)
.map((t, index) => ({
id: index + 1,
title: t.payee.name,
subtitle: t.tags.map((tag) => tag.name).join(", "),
amount: formatCurrency(t.amount),
timeAgo: new Date(t.occurred_at).toLocaleDateString("en-IN"),
}));
}

View File

@@ -1,18 +1,16 @@
import * as React from "react";
export interface LatestItem { export interface LatestItem {
id: string | number; id: string | number;
icon: React.ReactNode;
iconBgColor?: string;
title: string; title: string;
subtitle: string; subtitle: string;
amount: string; amount: string;
timeAgo: string; timeAgo: string;
} }
export interface LatestItemsListProps { export interface LatestItemsViewProps {
title?: string;
items: LatestItem[]; items: LatestItem[];
onViewAll?: () => void; header: string;
accentColor: string; accentColor: string;
canExpand: boolean;
onExpand: () => void;
isFetching?: boolean;
} }

View File

@@ -1,112 +1,47 @@
import * as React from "react"; import * as React from "react";
import { import { ReportData, GroupKey } from "../../features/report";
List, import { buildLatestItems } from "./LatestItems.adapter";
ListItem, import LatestItemsView from "./LatestItems.view";
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
Button,
} from "@mui/material";
export interface LatestItem { type Props = {
id: string | number; reportData: ReportData;
icon: React.ReactNode; flow: "outflows" | "inflows";
iconBgColor?: string; header: string;
title: string; selectedPeriodId: string | null;
subtitle: string; selectedGroupKey?: GroupKey | null;
amount: string; accentColor: string;
timeAgo: string; isFetching?: boolean;
} };
export interface LatestItemsListProps {
title?: string;
items: LatestItem[];
onViewAll?: () => void;
accentColor: any;
}
export default function LatestItems({ export default function LatestItems({
title = "Recent Transactions", reportData,
items, flow,
onViewAll, header,
selectedPeriodId,
selectedGroupKey = null,
accentColor, accentColor,
}: LatestItemsListProps) { isFetching,
}: Props) {
const [visibleCount, setVisibleCount] = React.useState(5);
const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow);
}, [reportData, selectedPeriodId, selectedGroupKey, flow]);
const visibleItems = React.useMemo(() => {
return allItems.slice(0, visibleCount);
}, [allItems, visibleCount]);
const canExpand = visibleCount < allItems.length;
return ( return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}> <LatestItemsView
{/* Header */} items={visibleItems}
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, px: 2 }}> header={header}
<Typography variant="h6" fontWeight="bold"> accentColor={accentColor}
{title} canExpand={canExpand}
</Typography> isFetching={isFetching}
{onViewAll && ( onExpand={() => setVisibleCount((prev) => prev + 5)}
<Button />
variant="text"
color="inherit"
size="small"
sx={{ textTransform: "none", color: "text.secondary", fontWeight: "medium" }}
onClick={onViewAll}
>
view all
</Button>
)}
</Box>
{/* List */}
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
transition: "background-color 0.2s ease",
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: `${accentColor}22`,
color: "inherit",
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
>
{item.icon}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600} color="text.primary">
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/>
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700} color="text.primary">
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}>
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
</List>
</Box>
); );
} }

View File

@@ -1,6 +1,90 @@
import LatestItemsListView from "./LatestItems.view"; import * as React from "react";
import { LatestItemsListProps } from "./LatestItems.models"; import {
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
IconButton,
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { LatestItemsViewProps } from "./LatestItems.models";
export default function LatestItemsList(props: LatestItemsListProps) { export default function LatestItemsView({
return <LatestItemsListView {...props} />; items,
header,
accentColor,
canExpand,
onExpand,
isFetching,
}: LatestItemsViewProps) {
return (
<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">
{header}
</Typography>
</Box>
<List disablePadding>
{items.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== items.length - 1 ? 1 : 0,
borderRadius: 3,
"&:hover": { bgcolor: "action.hover" },
}}
>
<ListItemAvatar>
<Avatar
variant="rounded"
sx={{
bgcolor: `${accentColor}22`,
width: 48,
height: 48,
borderRadius: 3,
mr: 2,
}}
/>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle1" fontWeight={600}>
{item.title}
</Typography>
}
secondary={
<Typography variant="body2" color="text.secondary">
{item.subtitle}
</Typography>
}
/>
<Box sx={{ textAlign: "right" }}>
<Typography variant="subtitle1" fontWeight={700}>
{item.amount}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.timeAgo}
</Typography>
</Box>
</ListItem>
))}
{canExpand && (
<Box sx={{ display: "flex", justifyContent: "center", mt: 2 }}>
<IconButton size="small" onClick={onExpand}>
<ExpandMoreIcon />
</IconButton>
</Box>
)}
</List>
</Box>
);
} }

View File

@@ -5,4 +5,7 @@ export interface ProgressCardProps {
totalAmount: number; totalAmount: number;
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning"; colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
compact?: boolean; compact?: boolean;
selected?: boolean;
onClick?: () => void;
isFetching?: boolean;
} }

View File

@@ -1,7 +1,7 @@
import * as React from "react"; import * as React from "react";
import ProgressCardView from "./ProgressCard.view"; import ProgressCardView from "./ProgressCard.view";
import { ProgressCardProps } from "./ProgressCard.models"; import { ProgressCardProps } from "./ProgressCard.models";
import { getPercentage, formatCurrency } from "./ProgressCard.utils"; import { getPercentage, formatCurrency } from "../report.helpers";
export default function ProgressCard(props: ProgressCardProps) { export default function ProgressCard(props: ProgressCardProps) {
const { progressAmount, totalAmount, compact = false } = props; const { progressAmount, totalAmount, compact = false } = props;
@@ -18,6 +18,8 @@ export default function ProgressCard(props: ProgressCardProps) {
formattedProgress={formattedProgress} formattedProgress={formattedProgress}
formattedTotal={formattedTotal} formattedTotal={formattedTotal}
compact={compact} compact={compact}
selected={props.selected}
onClick={props.onClick}
/> />
); );
} }

View File

@@ -1,15 +0,0 @@
export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
export const formatCurrency = (val: number) => {
const absVal = Math.abs(val);
if (absVal >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (absVal >= 1000) {
return `${(val / 1000).toFixed(2)}k`;
}
return `${val.toFixed(2)}`;
};

View File

@@ -14,6 +14,8 @@ interface ViewProps extends ProgressCardProps {
percentage: number; percentage: number;
formattedProgress: string; formattedProgress: string;
formattedTotal: string; formattedTotal: string;
selected?: boolean;
onClick?: () => void;
} }
export default function ProgressCardView({ export default function ProgressCardView({
@@ -23,6 +25,8 @@ export default function ProgressCardView({
formattedProgress, formattedProgress,
formattedTotal, formattedTotal,
compact = false, compact = false,
selected,
onClick,
}: ViewProps) { }: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === "dark";
@@ -30,10 +34,14 @@ export default function ProgressCardView({
return ( return (
<Paper <Paper
elevation={compact ? 2 : 4} elevation={compact ? 2 : 4}
onClick={onClick}
sx={{ sx={{
width: "100%", width: "100%",
p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 }, p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
borderRadius: compact ? 3 : 4, borderRadius: compact ? 3 : 4,
cursor: onClick ? "pointer" : "default",
transform: selected ? "scale(1.02)" : "scale(1)",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
background: (theme) => { background: (theme) => {
const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main; const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main;
const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light; const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light;
@@ -48,13 +56,21 @@ export default function ProgressCardView({
justifyContent: "center", justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none", border: selected
boxShadow: (theme) => ? `2px solid #fff`
`0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${ : isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
boxShadow: (theme) => {
const baseShadow = `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
isDark isDark
? "rgba(0,0,0,0.5)" ? "rgba(0,0,0,0.5)"
: theme.palette[colorTheme]?.main || theme.palette.primary.main : theme.palette[colorTheme]?.main || theme.palette.primary.main
}`, }`;
return selected
? `${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 <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

@@ -0,0 +1,71 @@
import { ReportData } from "../../features/report";
import {
mergeBucketPeriods,
periodIdToKey,
} from "../report.helpers";
import { GroupKey } from "../../features/report";
export interface TagItem {
tag: string;
amount: number;
}
export function extractTopTags(
reportData: ReportData,
flow: "outflows" | "inflows",
selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: TagItem[]; total: number } {
const tagMap = new Map<string, number>();
let periodKey: ReturnType<typeof periodIdToKey> = "all";
if (selectedPeriodId) {
periodKey = periodIdToKey(selectedPeriodId);
}
const periods = mergeBucketPeriods(reportData.buckets, periodKey);
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
);
}
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)
);
}
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);
}
}
}
const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({
tag,
amount,
}));
arr.sort((a, b) => b.amount - a.amount);
const top = arr.slice(0, 4);
const total = top.reduce((sum, t) => sum + t.amount, 0);
return { items: top, total };
}

View File

@@ -1,98 +1,93 @@
import * as React from "react"; import * as React from "react";
import { Box } from "@mui/material"; import { Box, Paper, Typography } from "@mui/material";
import { ReportData, ReportPeriod } from "../../features/report"; import { ReportData, GroupKey } from "../../features/report";
import ProgressCard from "./ProgressCard"; import ProgressCard from "./ProgressCard";
import { extractTopTags } from "./TopTags.adapter";
type Props = { type Props = {
reportData: ReportData; reportData: ReportData;
mode: "expense" | "income"; flow: "outflows" | "inflows";
header: string;
selectedPeriodId?: string | null; selectedPeriodId?: string | null;
selectedGroupKey?: GroupKey | null;
setSelectedGroupKey?: (key: GroupKey | null) => void;
compact?: boolean; compact?: boolean;
isFetching?: boolean;
}; };
type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
function getAmount(p: ReportPeriod, mode: "expense" | "income") {
return mode === "expense" ? p.expenses.sum : p.incomes.sum;
}
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
);
}
export default function TopTags({ export default function TopTags({
reportData, reportData,
mode, flow,
header,
selectedPeriodId, selectedPeriodId,
compact = true selectedGroupKey,
setSelectedGroupKey,
compact = true,
isFetching,
}: Props) { }: Props) {
const { items, total } = React.useMemo(() => { const { items, total } = React.useMemo(() => {
const tagMap = new Map<string, number>(); return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, flow, selectedPeriodId, selectedGroupKey]);
for (const bucket of reportData.buckets) {
const tags = bucket.group_key.tags;
if (!tags || tags.length === 0) continue;
// Prefer FULL if available
const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[];
const periodsToUse =
selectedPeriodId
? Object.values(bucket.periods).flat() as DecoratedPeriod[]
: fullPeriods;
const period = findPeriod(periodsToUse, selectedPeriodId);
if (!period) continue;
const amount = getAmount(period, mode);
for (const tag of tags) {
tagMap.set(tag, (tagMap.get(tag) || 0) + amount);
}
}
const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({
tag,
amount
}));
arr.sort((a, b) => b.amount - a.amount);
const top = arr.slice(0, 5);
const total = top.reduce((sum, t) => sum + t.amount, 0);
return { items: top, total };
}, [reportData, mode, selectedPeriodId]);
return ( return (
<Box sx={{ display: "grid", gap: 2 }}> <Paper
{items.map((item) => ( sx={{
<ProgressCard p: { xs: 2.5, sm: 4 },
key={item.tag} borderRadius: 4,
header={item.tag} width: "100%",
progressAmount={item.amount} boxShadow: "none",
totalAmount={total} border: "1px solid",
compact={compact} borderColor: "divider",
/> bgcolor: "background.paper",
))} opacity: isFetching ? 0.6 : 1,
</Box> 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?.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

@@ -0,0 +1,142 @@
import {
ReportPeriod,
ReportBucket,
GroupKey,
PeriodType,
} from "../features/report";
// ─── Types ────────────────────────────────────────────────────
export type PeriodKey = PeriodType;
export type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
// ─── Period helpers ───────────────────────────────────────────
const PREFIX_TO_KEY: Record<string, PeriodKey> = {
D: "daily",
W: "weekly",
M: "monthly",
ALL: "all",
};
/**
* Derive the period key from a decorated-period id.
* E.g. `"W:2026-04-28_2026-05-04"` → `"weekly"`
*/
export function periodIdToKey(periodId: string): PeriodKey {
const prefix = periodId.split(":")[0];
return PREFIX_TO_KEY[prefix] ?? "all";
}
// ─── Metric helpers ───────────────────────────────────────────
export function getAmount(period: ReportPeriod): number {
return period.metric.sum;
}
function mergeMetric(a: ReportPeriod["metric"], b: ReportPeriod["metric"]) {
const sum = a.sum + b.sum;
const count = a.count + b.count;
return {
...a,
sum,
count,
average: count > 0 ? sum / count : 0,
transactions:
a.transactions || b.transactions
? [...(a.transactions || []), ...(b.transactions || [])]
: undefined,
};
}
/**
* Merge periods with the same id across all buckets, summing
* their metrics and concatenating transactions.
*
* Returns sorted by start date ascending.
*/
export function mergeBucketPeriods(
buckets: ReportBucket[],
key: PeriodKey
): DecoratedPeriod[] {
const map = new Map<string, DecoratedPeriod>();
for (const bucket of buckets) {
const periods = (bucket.periods[key] || []) as DecoratedPeriod[];
for (const p of periods) {
const existing = map.get(p.id);
if (!existing) {
map.set(p.id, {
...p,
metric: { ...p.metric },
});
} else {
map.set(p.id, {
...existing,
metric: mergeMetric(existing.metric, p.metric),
});
}
}
}
return Array.from(map.values()).sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
);
}
// ─── Formatting ───────────────────────────────────────────────
export const formatCurrency = (val: number) => {
const absVal = Math.abs(val);
if (absVal >= 100000) {
return `${(val / 100000).toFixed(2)}L`;
}
if (absVal >= 1000) {
return `${(val / 1000).toFixed(2)}k`;
}
return `${val.toFixed(2)}`;
};
export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
// ─── Group filtering ──────────────────────────────────────────
/**
* Check if a bucket's group_key matches the selected GroupKey.
* Every dimension present in `selected` must exist in the bucket
* and contain all the selected values.
*/
export function matchesGroupKey(
bucket: ReportBucket,
selected: GroupKey
): boolean {
for (const [dim, values] of Object.entries(selected)) {
const bucketValues = bucket.group_key[dim];
if (!bucketValues) return false;
if (!(values as string[]).every((v) => bucketValues.includes(v)))
return false;
}
return true;
}
/**
* Return only buckets matching the selected group key,
* or all buckets if no selection.
*/
export function filterBuckets(
buckets: ReportBucket[],
selectedGroupKey: GroupKey | null
): ReportBucket[] {
if (!selectedGroupKey) return buckets;
return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey));
}

View File

@@ -2,6 +2,7 @@ import HistoryChart from "./components/HistoryChart";
import LatestItems from "./components/LatestItems"; import LatestItems from "./components/LatestItems";
import { DashboardConfig } from "./components/Dashboard"; import { DashboardConfig } from "./components/Dashboard";
import TopTags from "./components/ProgressCard/TopTags"; import TopTags from "./components/ProgressCard/TopTags";
import TopPayees from "./components/ProgressCard/TopPayees";
export const configuration: DashboardConfig = { export const configuration: DashboardConfig = {
sections: [ sections: [
@@ -12,15 +13,14 @@ export const configuration: DashboardConfig = {
component: HistoryChart, component: HistoryChart,
settings: { settings: {
tabs: ["Weekly", "Monthly"], tabs: ["Weekly", "Monthly"],
// tabs: ["Weekly", "Monthly", "Yearly", "Financial Year", "All Time"],
}, },
style: { style: {
size: 12, size: 12,
}, },
}, },
{ {
id: "top-payees", id: "top-categories",
title: 'Top Payees', title: 'Top Categories',
component: TopTags, component: TopTags,
settings: { settings: {
compact: true, compact: true,
@@ -29,19 +29,29 @@ export const configuration: DashboardConfig = {
size: 12, size: 12,
}, },
}, },
// { {
// id: "latest", id: "top-payees",
// title: 'Recent Transactions', title: 'Top Payees',
// component: LatestItems, component: TopPayees,
// dataKey: "latest", settings: {
// style: { compact: true,
// size: 12, },
// }, style: {
// }, size: 12,
},
},
{
id: "items",
title: 'Recent Transactions',
component: LatestItems,
style: {
size: 12,
},
},
], ],
style: { style: {
palette: { palette: {
expense: { outflows: {
light: { light: {
primary: "#d32f2f", primary: "#d32f2f",
background: "#fdecea", background: "#fdecea",
@@ -53,7 +63,7 @@ export const configuration: DashboardConfig = {
text: "#ffcdd2" text: "#ffcdd2"
} }
}, },
income: { inflows: {
light: { light: {
primary: "#2e7d32", primary: "#2e7d32",
background: "#e8f5e9", background: "#e8f5e9",

View File

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

View File

@@ -1,29 +1,40 @@
export interface Payor { export interface Payor {
id?: string;
name: string; name: string;
username: string;
email: string;
} }
export interface Payee { export interface Payee {
type: "merchant" | "person" | "transfer" | "other";
name: string; name: string;
} }
export interface Account { export interface Account {
id: string;
name: string; name: string;
number: string; number: string;
type: "cash" | "bank" | "credit_card" | "wallet" | "other";
currency: string;
is_active?: boolean;
} }
export interface Tag { export interface Tag {
id: string;
name: string; name: string;
icon: string; icon: string;
description: string; parent_id?: string | null;
} }
export interface Transaction { export interface Transaction {
id: string;
payor: Payor; payor: Payor;
payee: Payee; payee: Payee;
amount: number; amount: number;
account: Account; account: Account;
tags: Tag[]; tags: Tag[];
occurred_at: Date; occurred_at: string;
created_at: string;
} }
// ----------------------------- // -----------------------------
@@ -41,12 +52,12 @@ export interface ReportMetric {
// Period // Period
// ----------------------------- // -----------------------------
export interface ReportPeriod { export type PeriodType = "daily" | "weekly" | "monthly" | "all";
start: Date;
end: Date;
expenses: ReportMetric; export interface ReportPeriod {
incomes: ReportMetric; start: string;
end: string;
metric: ReportMetric;
} }
// ----------------------------- // -----------------------------
@@ -54,37 +65,48 @@ export interface ReportPeriod {
// ----------------------------- // -----------------------------
export type GroupKey = { export type GroupKey = {
payee?: string[]; [dimension: string]: string[];
tags?: string[];
flow?: string[];
}; };
export interface ReportBucket { export interface ReportBucket {
group_key: GroupKey; group_key: GroupKey;
periods: { periods: {
daily?: ReportPeriod[];
weekly?: ReportPeriod[]; weekly?: ReportPeriod[];
monthly?: ReportPeriod[]; monthly?: ReportPeriod[];
yearly?: ReportPeriod[]; all?: ReportPeriod[];
fyly?: ReportPeriod[];
full?: 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 // Final Report
// ----------------------------- // -----------------------------
export interface ReportData { export interface ReportData {
periods: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[]; snapshot_id?: string | null;
rolling: boolean; flow?: "inflows" | "outflows" | null;
report_date?: string;
group_by: ("payee" | "tags")[]; periods: PeriodType[];
ignore_self: boolean; tags?: string[] | null;
include_transactions: boolean; payee?: string[] | null;
buckets: ReportBucket[]; buckets: ReportBucket[];
query: ReportQuery;
} }

View File

@@ -1,6 +1,7 @@
import { import {
ReportData, ReportData,
ReportPeriod ReportPeriod,
PeriodType,
} from "./report.models"; } from "./report.models";
/* ---------- ID BUILDING ---------- */ /* ---------- ID BUILDING ---------- */
@@ -13,7 +14,7 @@ function formatDate(d: Date): string {
} }
function buildPeriodId( function buildPeriodId(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full", type: PeriodType,
start: Date, start: Date,
end: Date end: Date
): string { ): string {
@@ -21,16 +22,14 @@ function buildPeriodId(
const e = formatDate(end); const e = formatDate(end);
switch (type) { switch (type) {
case "daily":
return `D:${s}_${e}`;
case "weekly": case "weekly":
return `W:${s}_${e}`; return `W:${s}_${e}`;
case "monthly": case "monthly":
return `M:${s}_${e}`; return `M:${s}_${e}`;
case "yearly": case "all":
return `Y:${s}_${e}`; return `ALL:${s}_${e}`;
case "fyly":
return `FY:${s}_${e}`;
case "full":
return `FULL:${s}_${e}`;
default: default:
return `${s}_${e}`; return `${s}_${e}`;
} }
@@ -60,42 +59,23 @@ const yearFmt = new Intl.DateTimeFormat("en-GB", {
timeZone: "UTC", timeZone: "UTC",
}); });
function sameMonth(a: Date, b: Date) {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth()
);
}
function buildLabel( function buildLabel(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full", type: PeriodType,
start: Date, start: Date,
end: Date end: Date
): string { ): string {
switch (type) { switch (type) {
case "weekly": case "daily":
if (sameMonth(start, end)) { return dayFmt.format(start);
const sDay = start.getUTCDate();
const eDay = end.getUTCDate(); case "weekly": {
const m = monthFmt.format(start); const sDay = start.getUTCDate();
return `${sDay} ${m} - ${eDay} ${m}`; const m = monthFmt.format(start);
} return `${sDay} ${m}`;
return `${dayFmt.format(start)} - ${dayFmt.format(end)}`; }
case "monthly": case "monthly":
if (sameMonth(start, end)) { return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
}
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
case "yearly":
return yearFmt.format(start);
case "fyly": {
const startY = start.getUTCFullYear();
const endY = end.getUTCFullYear();
return `FY ${startY}${String(endY).slice(-2)}`;
}
default: default:
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`; return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
@@ -105,13 +85,13 @@ function buildLabel(
/* ---------- MAIN ---------- */ /* ---------- MAIN ---------- */
function decoratePeriods( function decoratePeriods(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full", type: PeriodType,
periods: ReportPeriod[] periods: ReportPeriod[]
): (ReportPeriod & { id: string; label: string })[] { ): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({ return periods.map((p) => ({
...p, ...p,
id: buildPeriodId(type, new Date(p.start), new Date(p.end)), id: buildPeriodId(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
label: buildLabel(type, new Date(p.start), new Date(p.end)), label: buildLabel(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
})); }));
} }

View File

@@ -1,12 +1,11 @@
import { useResourceByName } from "../../../react-openapi"; import { useResourceByName } from "../../../react-openapi";
export interface ReportParams { export interface ReportParams {
periods?: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[]; snapshot_id?: string;
rolling?: boolean; periods?: ("daily" | "weekly" | "monthly" | "all")[];
report_date?: string; flow?: "inflows" | "outflows";
group_by?: ("payee" | "tags")[]; payee?: string[];
ignore_self?: boolean; tags?: string[];
include_transactions?: boolean;
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {
@@ -15,6 +14,5 @@ export function useReport(params: ReportParams) {
return useList({ return useList({
...params, ...params,
periods: params.periods, periods: params.periods,
group_by: params.group_by,
}); });
} }