10 Commits

20 changed files with 394 additions and 154 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

@@ -11,20 +11,15 @@ import {
} from "@mui/material"; } from "@mui/material";
import ConfigurableDashboard from "./components/Dashboard"; import ConfigurableDashboard from "./components/Dashboard";
import { DashboardState } from "./components/Dashboard/Dashboard.models"; import { DashboardState } from "./components/Dashboard";
import { configuration } from "./dashboard-config"; import { configuration } from "./dashboard-config";
import { import {
useReport, useReport,
prepareReport, prepareReport,
} from "./features/report"; } from "./features/report";
/** Map the internal UI mode to the API flow param */
function modeToFlow(mode: "expense" | "income"): "outflows" | "inflows" {
return mode === "expense" ? "outflows" : "inflows";
}
export default function Dashboard() { export default function Dashboard() {
const [mode, setMode] = React.useState<"expense" | "income">("expense"); const [flow, setFlow] = React.useState<"outflows" | "inflows">("outflows");
const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]); const [appliedPayees, setAppliedPayees] = React.useState<string[]>([]);
const [appliedTags, setAppliedTags] = React.useState<string[]>([]); const [appliedTags, setAppliedTags] = React.useState<string[]>([]);
@@ -36,8 +31,8 @@ export default function Dashboard() {
const [loadedTags, setLoadedTags] = React.useState<string[]>([]); const [loadedTags, setLoadedTags] = React.useState<string[]>([]);
const report = useReport({ const report = useReport({
periods: ["weekly", "monthly", "all"], periods: ["daily", "weekly", "monthly", "all"],
flow: modeToFlow(mode), flow: flow,
payee: appliedPayees.length > 0 ? appliedPayees : undefined, payee: appliedPayees.length > 0 ? appliedPayees : undefined,
tags: appliedTags.length > 0 ? appliedTags : undefined, tags: appliedTags.length > 0 ? appliedTags : undefined,
}); });
@@ -77,9 +72,9 @@ export default function Dashboard() {
const isLoading = report.isLoading; const isLoading = report.isLoading;
const error = report.error; const error = report.error;
/** Callback for the ConfigurableDashboard's mode toggle */ /** Callback for the ConfigurableDashboard's flow toggle */
const handleModeChange = React.useCallback((newState: DashboardState) => { const handleFlowChange = React.useCallback((newState: DashboardState) => {
setMode(newState.mode); setFlow(newState.flow);
}, []); }, []);
if (isLoading && !report.data) { if (isLoading && !report.data) {
@@ -165,7 +160,8 @@ export default function Dashboard() {
<ConfigurableDashboard <ConfigurableDashboard
config={configuration} config={configuration}
data={data} data={data}
onModeChange={handleModeChange} isFetching={report.isFetching}
onFlowChange={handleFlowChange}
/> />
</Box> </Box>
); );

View File

@@ -4,12 +4,12 @@ import {
GroupKey, 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; selectedGroupKey: GroupKey | null;
@@ -43,12 +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;
onModeChange?: (state: DashboardState) => void; isFetching?: boolean;
onFlowChange?: (state: DashboardState) => void;
} }

View File

@@ -4,20 +4,25 @@ 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, selectedGroupKey: null,
comparison: false, comparison: false,
}); });
const toggleMode = () => { const toggleFlow = (
event: React.MouseEvent<HTMLElement>,
newFlow: "outflows" | "inflows" | null
) => {
if (newFlow === null) return;
setState(prev => { setState(prev => {
const next = { if (prev.flow === newFlow) return prev;
...prev,
mode: prev.mode === "expense" ? "income" as const : "expense" as const, const next = { ...prev, flow: newFlow };
}; props.onFlowChange?.(next);
props.onModeChange?.(next);
return next; return next;
}); });
}; };
@@ -49,7 +54,7 @@ export default function Dashboard(props: DashboardProps) {
{...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}

View File

@@ -5,7 +5,8 @@ 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 { GroupKey } from "../../features/report";
@@ -14,7 +15,7 @@ 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; setSelectedGroupKey: (groupKey: GroupKey | null) => void;
@@ -26,7 +27,7 @@ export default function DashboardView({
data, data,
state, state,
setState, setState,
toggleMode, toggleFlow,
togglePeriodType, togglePeriodType,
toggleComparison, toggleComparison,
setSelectedPeriodId, setSelectedPeriodId,
@@ -34,11 +35,11 @@ export default function DashboardView({
}: ViewProps) { }: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const themeMode = theme.palette.mode; const themeMode = theme.palette.mode;
const { mode, periodType, comparison, selectedPeriodId, selectedGroupKey } = 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) {
@@ -50,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
@@ -69,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",
@@ -89,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}>
@@ -100,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}
@@ -118,7 +121,7 @@ export default function DashboardView({
colorScheme={colors} colorScheme={colors}
// State management // State management
mode={mode} flow={flow}
periodType={periodType} periodType={periodType}
comparison={comparison} comparison={comparison}
@@ -129,6 +132,7 @@ export default function DashboardView({
toggleComparison={toggleComparison} toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId} setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey} setSelectedGroupKey={setSelectedGroupKey}
isFetching={arguments[0].isFetching}
/> />
</Grid> </Grid>
); );

View File

@@ -54,7 +54,7 @@ function attachComparison(
export function buildChartData( export function buildChartData(
reportData: ReportData, reportData: ReportData,
key: PeriodKey, key: PeriodKey,
mode: "expense" | "income", flow: "outflows" | "inflows",
comparison: boolean comparison: boolean
): ChartDataPoint[] { ): ChartDataPoint[] {
const merged = mergeBucketPeriods(reportData.buckets, key); const merged = mergeBucketPeriods(reportData.buckets, key);

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

@@ -7,7 +7,7 @@ export default function HistoryChart(props: HistoryChartProps) {
const { const {
tabs, tabs,
reportData, reportData,
mode, flow,
comparison, comparison,
selectedPeriodId, selectedPeriodId,
setSelectedPeriodId setSelectedPeriodId
@@ -19,8 +19,8 @@ export default function HistoryChart(props: HistoryChartProps) {
const activeDataKey = tabToKey(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

View File

@@ -35,7 +35,7 @@ export default function HistoryChartView(props: ViewProps) {
tabs, tabs,
colorScheme, colorScheme,
mode, flow,
periodType, periodType,
selectedPeriodId, selectedPeriodId,
comparison, comparison,
@@ -92,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

@@ -14,24 +14,43 @@ function extractTransactions(
selectedPeriodId: string | null, selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null, selectedGroupKey: GroupKey | null,
): Transaction[] { ): Transaction[] {
const buckets = filterBuckets(reportData.buckets, selectedGroupKey); // 1. Get raw transactions
let rawTxns: Transaction[] = [];
if (selectedPeriodId) { if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId); const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(buckets, key); const periods = mergeBucketPeriods(reportData.buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId); const selected = periods.find((p) => p.id === selectedPeriodId);
rawTxns = selected?.metric.transactions || [];
if (!selected) return []; } else {
const periods = mergeBucketPeriods(reportData.buckets, "all");
return selected.metric.transactions || []; if (periods.length > 0) {
rawTxns = periods[0].metric.transactions || [];
}
} }
const periods = mergeBucketPeriods(buckets, "all"); // 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 []; return rawTxns;
const full = periods[0];
return full.metric.transactions || [];
} }
// ─── Main adapter ──────────────────────────────────────────── // ─── Main adapter ────────────────────────────────────────────
@@ -40,7 +59,7 @@ export function buildLatestItems(
reportData: ReportData, reportData: ReportData,
selectedPeriodId: string | null, selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null, selectedGroupKey: GroupKey | null,
mode: "expense" | "income" flow: "outflows" | "inflows"
): LatestItem[] { ): LatestItem[] {
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey); const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey);

View File

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

View File

@@ -5,39 +5,42 @@ import LatestItemsView from "./LatestItems.view";
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; selectedGroupKey?: GroupKey | null;
accentColor: string; accentColor: string;
isFetching?: boolean;
}; };
export default function LatestItems({ export default function LatestItems({
reportData, reportData,
mode, flow,
header,
selectedPeriodId, selectedPeriodId,
selectedGroupKey = null, selectedGroupKey = null,
accentColor, accentColor,
isFetching,
}: Props) { }: Props) {
const [visibleCount, setVisibleCount] = React.useState(5); const [visibleCount, setVisibleCount] = React.useState(5);
const allItems = React.useMemo(() => { const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, mode); return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, flow);
}, [reportData, selectedPeriodId, selectedGroupKey, mode]); }, [reportData, selectedPeriodId, selectedGroupKey, flow]);
const hasSelection = Boolean(selectedPeriodId) || Boolean(selectedGroupKey);
const visibleItems = React.useMemo(() => { const visibleItems = React.useMemo(() => {
if (!hasSelection) return allItems.slice(0, 5);
return allItems.slice(0, visibleCount); return allItems.slice(0, visibleCount);
}, [allItems, hasSelection, visibleCount]); }, [allItems, visibleCount]);
const canExpand = hasSelection && visibleCount < allItems.length; const canExpand = visibleCount < allItems.length;
return ( return (
<LatestItemsView <LatestItemsView
items={visibleItems} items={visibleItems}
header={header}
accentColor={accentColor} accentColor={accentColor}
canExpand={canExpand} canExpand={canExpand}
isFetching={isFetching}
onExpand={() => setVisibleCount((prev) => prev + 5)} onExpand={() => setVisibleCount((prev) => prev + 5)}
/> />
); );

View File

@@ -14,15 +14,17 @@ import { LatestItemsViewProps } from "./LatestItems.models";
export default function LatestItemsView({ export default function LatestItemsView({
items, items,
header,
accentColor, accentColor,
canExpand, canExpand,
onExpand, onExpand,
isFetching,
}: LatestItemsViewProps) { }: LatestItemsViewProps) {
return ( 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 }}> <Box sx={{ mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold"> <Typography variant="h6" fontWeight="bold">
Recent Transactions {header}
</Typography> </Typography>
</Box> </Box>

View File

@@ -7,4 +7,5 @@ export interface ProgressCardProps {
compact?: boolean; compact?: boolean;
selected?: boolean; selected?: boolean;
onClick?: () => void; 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}, 0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette[colorTheme]?.main || theme.palette.primary.main}`
: baseShadow; : 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

@@ -1,31 +1,10 @@
import { ReportData } from "../../features/report"; import { ReportData } from "../../features/report";
import { import {
getAmount, mergeBucketPeriods,
DecoratedPeriod, periodIdToKey,
} from "../report.helpers"; } from "../report.helpers";
// ─── Helpers ───────────────────────────────────────────────── import { GroupKey } from "../../features/report";
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 ────────────────────────────────────────────
export interface TagItem { export interface TagItem {
tag: string; tag: string;
@@ -34,29 +13,47 @@ export interface TagItem {
export function extractTopTags( export function extractTopTags(
reportData: ReportData, reportData: ReportData,
mode: "expense" | "income", flow: "outflows" | "inflows",
selectedPeriodId?: string | null selectedPeriodId?: string | null,
selectedGroupKey?: GroupKey | null
): { items: TagItem[]; total: number } { ): { items: TagItem[]; total: number } {
const tagMap = new Map<string, number>(); const tagMap = new Map<string, number>();
for (const bucket of reportData.buckets) { let periodKey: ReturnType<typeof periodIdToKey> = "all";
const tags = bucket.group_key.tags; if (selectedPeriodId) {
if (!tags || tags.length === 0) continue; periodKey = periodIdToKey(selectedPeriodId);
}
// Prefer ALL if available const periods = mergeBucketPeriods(reportData.buckets, periodKey);
const allPeriods = (bucket.periods.all || []) as DecoratedPeriod[];
const periodsToUse = selectedPeriodId let period = periods[0];
? (Object.values(bucket.periods).flat() as DecoratedPeriod[]) if (selectedPeriodId) {
: allPeriods; 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 && period.metric && period.metric.transactions) {
if (!period) continue; 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); for (const txn of txns) {
if (txn.tags && txn.tags.length > 0) {
for (const tag of tags) { for (const tagObj of txn.tags) {
tagMap.set(tag, (tagMap.get(tag) || 0) + amount); 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 * as React from "react";
import { Box } from "@mui/material"; import { Box, Paper, Typography } from "@mui/material";
import { ReportData, GroupKey } from "../../features/report"; import { ReportData, GroupKey } from "../../features/report";
import ProgressCard from "./ProgressCard"; import ProgressCard from "./ProgressCard";
import { extractTopTags } from "./TopTags.adapter"; 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; selectedGroupKey?: GroupKey | null;
setSelectedGroupKey?: (key: GroupKey | null) => void; setSelectedGroupKey?: (key: GroupKey | null) => void;
compact?: boolean; compact?: boolean;
isFetching?: boolean;
}; };
export default function TopTags({ export default function TopTags({
reportData, reportData,
mode, flow,
header,
selectedPeriodId, selectedPeriodId,
selectedGroupKey, selectedGroupKey,
setSelectedGroupKey, setSelectedGroupKey,
compact = true, compact = true,
isFetching,
}: Props) { }: Props) {
const { items, total } = React.useMemo(() => { const { items, total } = React.useMemo(() => {
return extractTopTags(reportData, mode, selectedPeriodId); return extractTopTags(reportData, flow, selectedPeriodId, selectedGroupKey);
}, [reportData, mode, selectedPeriodId]); }, [reportData, flow, selectedPeriodId, selectedGroupKey]);
return ( return (
<Box <Paper
sx={{ sx={{
display: "grid", p: { xs: 2.5, sm: 4 },
gridTemplateColumns: { borderRadius: 4,
xs: "1fr", width: "100%",
sm: "repeat(2, 1fr)", boxShadow: "none",
md: "repeat(4, 1fr)", border: "1px solid",
}, borderColor: "divider",
gap: 2, bgcolor: "background.paper",
opacity: isFetching ? 0.6 : 1,
transition: "opacity 0.3s ease",
pointerEvents: isFetching ? "none" : "auto",
}} }}
> >
{items.map((item) => { <Typography variant="h6" fontWeight={700} gutterBottom>
const isSelected = selectedGroupKey?.tags?.includes(item.tag); {header}
return ( </Typography>
<ProgressCard
key={item.tag} <Box
header={item.tag} sx={{
progressAmount={item.amount} display: "grid",
totalAmount={total} gridTemplateColumns: {
compact={compact} xs: "1fr",
colorTheme={mode === "expense" ? "error" : "success"} sm: "repeat(2, 1fr)",
selected={isSelected} md: "repeat(4, 1fr)",
onClick={() => { },
if (setSelectedGroupKey) { gap: 2,
setSelectedGroupKey(isSelected ? null : { tags: [item.tag] }); }}
} >
}} {items.map((item) => {
/> const isSelected = selectedGroupKey?.tags?.includes(item.tag);
); return (
})} <ProgressCard
</Box> 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,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,7 +13,6 @@ 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,
@@ -29,8 +29,20 @@ export const configuration: DashboardConfig = {
size: 12, size: 12,
}, },
}, },
{
id: "top-payees",
title: 'Top Payees',
component: TopPayees,
settings: {
compact: true,
},
style: {
size: 12,
},
},
{ {
id: "items", id: "items",
title: 'Recent Transactions',
component: LatestItems, component: LatestItems,
style: { style: {
size: 12, size: 12,
@@ -39,7 +51,7 @@ export const configuration: DashboardConfig = {
], ],
style: { style: {
palette: { palette: {
expense: { outflows: {
light: { light: {
primary: "#d32f2f", primary: "#d32f2f",
background: "#fdecea", background: "#fdecea",
@@ -51,7 +63,7 @@ export const configuration: DashboardConfig = {
text: "#ffcdd2" text: "#ffcdd2"
} }
}, },
income: { inflows: {
light: { light: {
primary: "#2e7d32", primary: "#2e7d32",
background: "#e8f5e9", background: "#e8f5e9",