items-by-period #2

Merged
aetos merged 7 commits from items-by-period into main 2026-05-09 13:00:43 +00:00
8 changed files with 117 additions and 402 deletions
Showing only changes of commit 1423f889ba - Show all commits

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, mode,
periodType,
comparison, comparison,
selectedPeriodId, selectedPeriodId,
setSelectedPeriodId setSelectedPeriodId
@@ -136,7 +16,7 @@ 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, mode, comparison);
@@ -184,7 +64,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

@@ -1,18 +1,14 @@
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;
accentColor: string; accentColor: string;
canExpand: boolean;
onExpand: () => void;
} }

View File

@@ -1,18 +1,7 @@
import * as React from "react"; import * as React from "react";
import { import { ReportData } from "../../features/report";
List, import { buildLatestItems } from "./LatestItems.adapter";
ListItem, import LatestItemsView from "./LatestItems.view";
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
IconButton
} from "@mui/material";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { ReportData, Transaction, ReportPeriod } from "../../features/report";
import { formatCurrency } from "../ProgressCard/ProgressCard.utils";
type Props = { type Props = {
reportData: ReportData; reportData: ReportData;
@@ -21,190 +10,33 @@ type Props = {
accentColor: string; accentColor: string;
}; };
type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
function mergePeriods(
reportData: ReportData,
key: "weekly" | "monthly" | "yearly" | "fyly" | "full"
): DecoratedPeriod[] {
const map = new Map<string, DecoratedPeriod>();
for (const bucket of reportData.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,
transactions: [...(p.expenses.transactions || [])],
},
incomes: {
...p.incomes,
transactions: [...(p.incomes.transactions || [])],
},
});
} else {
existing.expenses.transactions?.push(...(p.expenses.transactions || []));
existing.incomes.transactions?.push(...(p.incomes.transactions || []));
}
}
}
return Array.from(map.values());
}
function extractTransactions(
reportData: ReportData,
selectedPeriodId: string | null,
mode: "expense" | "income",
): Transaction[] {
let periods: DecoratedPeriod[] = [];
if (selectedPeriodId) {
const prefix = selectedPeriodId.split(":")[0];
const map: any = {
W: "weekly",
M: "monthly",
Y: "yearly",
FY: "fyly",
FULL: "full"
};
const key = map[prefix];
periods = mergePeriods(reportData, key);
const selected = periods.find(p => p.id === selectedPeriodId);
if (!selected) return [];
return mode === "expense"
? (selected.expenses.transactions || [])
: (selected.incomes.transactions || []);
}
periods = mergePeriods(reportData, "full");
if (!periods.length) return [];
const full = periods[0];
return mode === "expense"
? (full.expenses.transactions || [])
: (full.incomes.transactions || []);
}
export default function LatestItems({ export default function LatestItems({
reportData, reportData,
mode, mode,
selectedPeriodId, selectedPeriodId,
accentColor accentColor,
}: Props) { }: Props) {
const [visibleCount, setVisibleCount] = React.useState(5); const [visibleCount, setVisibleCount] = React.useState(5);
const items = React.useMemo(() => { const allItems = React.useMemo(() => {
const txns = extractTransactions(reportData, selectedPeriodId, mode); return buildLatestItems(reportData, selectedPeriodId, mode);
return txns
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))
.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"),
}));
}, [reportData, selectedPeriodId, mode]); }, [reportData, selectedPeriodId, mode]);
const isPeriodSelected = Boolean(selectedPeriodId); const isPeriodSelected = Boolean(selectedPeriodId);
const visibleItems = React.useMemo(() => { const visibleItems = React.useMemo(() => {
if (!isPeriodSelected) return items.slice(0, 5); if (!isPeriodSelected) return allItems.slice(0, 5);
return items.slice(0, visibleCount); return allItems.slice(0, visibleCount);
}, [items, isPeriodSelected, visibleCount]); }, [allItems, isPeriodSelected, visibleCount]);
const canExpand = isPeriodSelected && visibleCount < items.length; const canExpand = isPeriodSelected && visibleCount < allItems.length;
return ( return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}> <LatestItemsView
<Box sx={{ mb: 2, px: 2 }}> items={visibleItems}
<Typography variant="h6" fontWeight="bold"> accentColor={accentColor}
Recent Transactions canExpand={canExpand}
</Typography> onExpand={() => setVisibleCount((prev) => prev + 5)}
</Box> />
<List disablePadding>
{visibleItems.map((item, index) => (
<ListItem
key={item.id}
sx={{
px: { xs: 1, sm: 2 },
py: 2,
mb: index !== visibleItems.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={() => setVisibleCount((prev) => prev + 5)}
>
<ExpandMoreIcon />
</IconButton>
</Box>
)}
</List>
</Box>
); );
} }

View File

@@ -1,6 +1,88 @@
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,
accentColor,
canExpand,
onExpand,
}: LatestItemsViewProps) {
return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
<Box sx={{ mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold">
Recent Transactions
</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

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

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

@@ -1,7 +1,8 @@
import * as React from "react"; import * as React from "react";
import { Box } from "@mui/material"; import { Box } from "@mui/material";
import { ReportData, ReportPeriod } from "../../features/report"; import { ReportData } from "../../features/report";
import ProgressCard from "./ProgressCard"; import ProgressCard from "./ProgressCard";
import { extractTopTags } from "./TopTags.adapter";
type Props = { type Props = {
reportData: ReportData; reportData: ReportData;
@@ -10,76 +11,14 @@ type Props = {
compact?: boolean; compact?: 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, mode,
selectedPeriodId, selectedPeriodId,
compact = true compact = true,
}: Props) { }: Props) {
const { items, total } = React.useMemo(() => { const { items, total } = React.useMemo(() => {
const tagMap = new Map<string, number>(); return extractTopTags(reportData, mode, selectedPeriodId);
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, 4);
const total = top.reduce((sum, t) => sum + t.amount, 0);
return { items: top, total };
}, [reportData, mode, selectedPeriodId]); }, [reportData, mode, selectedPeriodId]);
return ( return (
@@ -89,9 +28,9 @@ export default function TopTags({
gridTemplateColumns: { gridTemplateColumns: {
xs: "1fr", xs: "1fr",
sm: "repeat(2, 1fr)", sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)" md: "repeat(4, 1fr)",
}, },
gap: 2 gap: 2,
}} }}
> >
{items.map((item) => ( {items.map((item) => (

View File

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