5 Commits

Author SHA1 Message Date
f5322b8467 cleanup 2026-05-09 13:30:01 +05:30
1423f889ba cleanup 2026-05-09 13:29:49 +05:30
4c8552051c weekly label fix 2026-05-07 19:45:53 +05:30
f025a7d9bf expand fixes 2026-05-07 17:32:16 +05:30
052c5a3026 enabled latest items 2026-05-07 17:29:09 +05:30
15 changed files with 39 additions and 252 deletions

View File

@@ -3,11 +3,7 @@ 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";
@@ -18,67 +14,18 @@ import {
} from "./features/report"; } from "./features/report";
export default function Dashboard() { export default function Dashboard() {
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: ["weekly", "monthly", "full"],
rolling: true, rolling: true,
include_transactions: true, include_transactions: true,
group_by: ["tags"], group_by: ["tags"],
payee: appliedPayees.length > 0 ? appliedPayees : undefined, })
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.expenses?.transactions?.forEach((t: any) => {
if (t.payee?.name) pSet.add(t.payee.name);
});
p.incomes?.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.expenses?.transactions?.forEach((t: any) => {
t.tags?.forEach((tag: any) => tSet.add(tag.name || tag));
});
p.incomes?.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;
if (isLoading && !report.data) { if (isLoading) {
return ( return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}> <Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress /> <CircularProgress />
@@ -94,74 +41,15 @@ export default function Dashboard() {
); );
} }
if (!report.data) { if (!report) {
return null; return null;
} }
const data = prepareReport(report.data.data); const data = prepareReport(report.data?.data);
return ( return (
<Box> <ConfigurableDashboard
<Container> config={configuration}
<Paper data={data}
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 }} // Changed from 56 to 40 to match minHeight of inputs
>
Apply
</Button>
</Paper>
</Container>
<ConfigurableDashboard
config={configuration}
data={data}
/>
</Box>
); );
} }

View File

@@ -1,7 +1,6 @@
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 DashboardMode = "expense" | "income";
@@ -12,7 +11,6 @@ export interface DashboardState {
mode: DashboardMode; mode: DashboardMode;
periodType: DashboardPeriodType; periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId; selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean; comparison: boolean;
} }

View File

@@ -7,7 +7,6 @@ export default function Dashboard(props: DashboardProps) {
mode: "expense", mode: "expense",
periodType: "rolling", periodType: "rolling",
selectedPeriodId: null, selectedPeriodId: null,
selectedGroupKey: null,
comparison: false, comparison: false,
}); });
@@ -36,10 +35,6 @@ 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}
@@ -49,7 +44,6 @@ export default function Dashboard(props: DashboardProps) {
togglePeriodType={togglePeriodType} togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison} toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId} setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
/> />
); );
} }

View File

@@ -8,7 +8,6 @@ import {
ToggleButtonGroup ToggleButtonGroup
} 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 {
@@ -17,7 +16,6 @@ interface ViewProps extends DashboardProps {
toggleMode: () => void; toggleMode: () => void;
togglePeriodType: () => void; togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void; setSelectedPeriodId: (id: string | null) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleComparison: () => void; toggleComparison: () => void;
} }
@@ -30,11 +28,10 @@ export default function DashboardView({
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, selectedGroupKey } = state; const { mode, periodType, comparison, selectedPeriodId } = state;
// Resolve colors with fallbacks // Resolve colors with fallbacks
const colors = React.useMemo(() => { const colors = React.useMemo(() => {
@@ -123,12 +120,10 @@ export default function DashboardView({
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}
/> />
</Grid> </Grid>
); );

View File

@@ -1,9 +1,8 @@
import { ReportData, Transaction, GroupKey } from "../../features/report"; import { ReportData, Transaction } from "../../features/report";
import { import {
mergeBucketPeriods, mergeBucketPeriods,
periodIdToKey, periodIdToKey,
formatCurrency, formatCurrency,
filterBuckets,
} from "../report.helpers"; } from "../report.helpers";
import { LatestItem } from "./LatestItems.models"; import { LatestItem } from "./LatestItems.models";
@@ -12,13 +11,11 @@ import { LatestItem } from "./LatestItems.models";
function extractTransactions( function extractTransactions(
reportData: ReportData, reportData: ReportData,
selectedPeriodId: string | null, selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
mode: "expense" | "income" mode: "expense" | "income"
): Transaction[] { ): Transaction[] {
const buckets = filterBuckets(reportData.buckets, selectedGroupKey);
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);
if (!selected) return []; if (!selected) return [];
@@ -28,7 +25,7 @@ function extractTransactions(
: (selected.incomes.transactions || []); : (selected.incomes.transactions || []);
} }
const periods = mergeBucketPeriods(buckets, "full"); const periods = mergeBucketPeriods(reportData.buckets, "full");
if (!periods.length) return []; if (!periods.length) return [];
@@ -44,10 +41,9 @@ function extractTransactions(
export function buildLatestItems( export function buildLatestItems(
reportData: ReportData, reportData: ReportData,
selectedPeriodId: string | null, selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
mode: "expense" | "income" mode: "expense" | "income"
): LatestItem[] { ): LatestItem[] {
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode); const txns = extractTransactions(reportData, selectedPeriodId, mode);
return txns return txns
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0)) .filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))

View File

@@ -1,5 +1,5 @@
import * as React from "react"; import * as React from "react";
import { ReportData, GroupKey } from "../../features/report"; import { ReportData } from "../../features/report";
import { buildLatestItems } from "./LatestItems.adapter"; import { buildLatestItems } from "./LatestItems.adapter";
import LatestItemsView from "./LatestItems.view"; import LatestItemsView from "./LatestItems.view";
@@ -7,7 +7,6 @@ type Props = {
reportData: ReportData; reportData: ReportData;
mode: "expense" | "income"; mode: "expense" | "income";
selectedPeriodId: string | null; selectedPeriodId: string | null;
selectedGroupKey?: GroupKey | null;
accentColor: string; accentColor: string;
}; };
@@ -15,23 +14,22 @@ export default function LatestItems({
reportData, reportData,
mode, mode,
selectedPeriodId, selectedPeriodId,
selectedGroupKey = null,
accentColor, accentColor,
}: 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, mode);
}, [reportData, selectedPeriodId, selectedGroupKey, mode]); }, [reportData, selectedPeriodId, mode]);
const hasSelection = Boolean(selectedPeriodId) || Boolean(selectedGroupKey); const isPeriodSelected = Boolean(selectedPeriodId);
const visibleItems = React.useMemo(() => { const visibleItems = React.useMemo(() => {
if (!hasSelection) return allItems.slice(0, 5); if (!isPeriodSelected) return allItems.slice(0, 5);
return allItems.slice(0, visibleCount); return allItems.slice(0, visibleCount);
}, [allItems, hasSelection, visibleCount]); }, [allItems, isPeriodSelected, visibleCount]);
const canExpand = hasSelection && visibleCount < allItems.length; const canExpand = isPeriodSelected && visibleCount < allItems.length;
return ( return (
<LatestItemsView <LatestItemsView

View File

@@ -5,6 +5,4 @@ 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;
} }

View File

@@ -18,8 +18,6 @@ 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

@@ -14,8 +14,6 @@ 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({
@@ -25,8 +23,6 @@ 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";
@@ -34,14 +30,10 @@ 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;
@@ -56,19 +48,13 @@ export default function ProgressCardView({
justifyContent: "center", justifyContent: "center",
position: "relative", position: "relative",
overflow: "hidden", overflow: "hidden",
border: selected border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
? `2px solid #fff` boxShadow: (theme) =>
: isDark ? "1px solid rgba(255,255,255,0.1)" : "none", `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
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;
},
}} }}
> >
<Typography <Typography

View File

@@ -1,6 +1,6 @@
import * as React from "react"; import * as React from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { ReportData, GroupKey } from "../../features/report"; import { ReportData } from "../../features/report";
import ProgressCard from "./ProgressCard"; import ProgressCard from "./ProgressCard";
import { extractTopTags } from "./TopTags.adapter"; import { extractTopTags } from "./TopTags.adapter";
@@ -8,8 +8,6 @@ type Props = {
reportData: ReportData; reportData: ReportData;
mode: "expense" | "income"; mode: "expense" | "income";
selectedPeriodId?: string | null; selectedPeriodId?: string | null;
selectedGroupKey?: GroupKey | null;
setSelectedGroupKey?: (key: GroupKey | null) => void;
compact?: boolean; compact?: boolean;
}; };
@@ -17,8 +15,6 @@ export default function TopTags({
reportData, reportData,
mode, mode,
selectedPeriodId, selectedPeriodId,
selectedGroupKey,
setSelectedGroupKey,
compact = true, compact = true,
}: Props) { }: Props) {
const { items, total } = React.useMemo(() => { const { items, total } = React.useMemo(() => {
@@ -37,25 +33,16 @@ export default function TopTags({
gap: 2, gap: 2,
}} }}
> >
{items.map((item) => { {items.map((item) => (
const isSelected = selectedGroupKey?.tags?.includes(item.tag); <ProgressCard
return ( key={item.tag}
<ProgressCard header={item.tag}
key={item.tag} progressAmount={item.amount}
header={item.tag} totalAmount={total}
progressAmount={item.amount} compact={compact}
totalAmount={total} colorTheme={mode === "expense" ? "error" : "success"}
compact={compact} />
colorTheme={mode === "expense" ? "error" : "success"} ))}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
setSelectedGroupKey(isSelected ? null : { tags: [item.tag] });
}
}}
/>
);
})}
</Box> </Box>
); );
} }

View File

@@ -1,7 +1,6 @@
import { import {
ReportPeriod, ReportPeriod,
ReportBucket, ReportBucket,
GroupKey,
} from "../features/report"; } from "../features/report";
// ─── Types ──────────────────────────────────────────────────── // ─── Types ────────────────────────────────────────────────────
@@ -113,35 +112,3 @@ export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0; if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100)); 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 as keyof GroupKey];
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

@@ -19,8 +19,8 @@ export const configuration: DashboardConfig = {
}, },
}, },
{ {
id: "top-categories", id: "top-payees",
title: 'Top Categories', title: 'Top Payees',
component: TopTags, component: TopTags,
settings: { settings: {
compact: true, compact: true,

View File

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

View File

@@ -86,14 +86,5 @@ export interface ReportData {
ignore_self: boolean; ignore_self: boolean;
include_transactions: boolean; include_transactions: boolean;
start_date?: string | null;
end_date?: string | null;
flow?: "expense" | "income" | null;
payee?: string[] | null;
account?: string[] | null;
tags?: string[] | null;
min_amount?: number | null;
max_amount?: number | null;
buckets: ReportBucket[]; buckets: ReportBucket[];
} }

View File

@@ -7,14 +7,6 @@ export interface ReportParams {
group_by?: ("payee" | "tags")[]; group_by?: ("payee" | "tags")[];
ignore_self?: boolean; ignore_self?: boolean;
include_transactions?: boolean; include_transactions?: boolean;
start_date?: string;
end_date?: string;
flow?: "expense" | "income";
payee?: string[];
account?: string[];
tags?: string[];
min_amount?: number;
max_amount?: number;
} }
export function useReport(params: ReportParams) { export function useReport(params: ReportParams) {