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,
Container,
CircularProgress,
Alert,
TextField,
Paper,
Autocomplete,
Button
Alert
} from "@mui/material";
import ConfigurableDashboard from "./components/Dashboard";
@@ -18,67 +14,18 @@ import {
} from "./features/report";
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({
periods: ["weekly", "monthly", "full"],
rolling: true,
include_transactions: true,
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 error = report.error;
if (isLoading && !report.data) {
if (isLoading) {
return (
<Box sx={{ display: "flex", justifyContent: "center", alignItems: "center", height: "60vh" }}>
<CircularProgress />
@@ -94,74 +41,15 @@ export default function Dashboard() {
);
}
if (!report.data) {
if (!report) {
return null;
}
const data = prepareReport(report.data.data);
const data = prepareReport(report.data?.data);
return (
<Box>
<Container>
<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 }} // 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 {
ReportData,
GroupKey,
ReportData
} from "../../features/report";
export type DashboardMode = "expense" | "income";
@@ -12,7 +11,6 @@ export interface DashboardState {
mode: DashboardMode;
periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean;
}

View File

@@ -7,7 +7,6 @@ export default function Dashboard(props: DashboardProps) {
mode: "expense",
periodType: "rolling",
selectedPeriodId: null,
selectedGroupKey: null,
comparison: false,
});
@@ -36,10 +35,6 @@ export default function Dashboard(props: DashboardProps) {
setState(prev => ({ ...prev, selectedPeriodId }));
};
const setSelectedGroupKey = (groupKey: typeof state.selectedGroupKey) => {
setState(prev => ({ ...prev, selectedGroupKey: groupKey }));
};
return (
<DashboardView
{...props}
@@ -49,7 +44,6 @@ export default function Dashboard(props: DashboardProps) {
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
/>
);
}

View File

@@ -8,7 +8,6 @@ import {
ToggleButtonGroup
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { GroupKey } from "../../features/report";
import { DashboardProps, DashboardState } from "./Dashboard.models";
interface ViewProps extends DashboardProps {
@@ -17,7 +16,6 @@ interface ViewProps extends DashboardProps {
toggleMode: () => void;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleComparison: () => void;
}
@@ -30,11 +28,10 @@ export default function DashboardView({
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
}: ViewProps) {
const theme = useTheme();
const themeMode = theme.palette.mode;
const { mode, periodType, comparison, selectedPeriodId, selectedGroupKey } = state;
const { mode, periodType, comparison, selectedPeriodId } = state;
// Resolve colors with fallbacks
const colors = React.useMemo(() => {
@@ -123,12 +120,10 @@ export default function DashboardView({
periodType={periodType}
comparison={comparison}
selectedPeriodId={selectedPeriodId}
selectedGroupKey={selectedGroupKey}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
/>
</Grid>
);

View File

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

View File

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

View File

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

View File

@@ -18,8 +18,6 @@ export default function ProgressCard(props: ProgressCardProps) {
formattedProgress={formattedProgress}
formattedTotal={formattedTotal}
compact={compact}
selected={props.selected}
onClick={props.onClick}
/>
);
}

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
import {
ReportPeriod,
ReportBucket,
GroupKey,
} from "../features/report";
// ─── Types ────────────────────────────────────────────────────
@@ -113,35 +112,3 @@ 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 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",
title: 'Top Categories',
id: "top-payees",
title: 'Top Payees',
component: TopTags,
settings: {
compact: true,

View File

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

View File

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

View File

@@ -7,14 +7,6 @@ export interface ReportParams {
group_by?: ("payee" | "tags")[];
ignore_self?: 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) {