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>
This commit is contained in:
2026-05-09 13:00:42 +00:00
committed by aetos
parent f213a9455b
commit 77b60ba073
19 changed files with 574 additions and 357 deletions

View File

@@ -0,0 +1,66 @@
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,
mode: "expense" | "income"
): Transaction[] {
const buckets = filterBuckets(reportData.buckets, selectedGroupKey);
if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId);
if (!selected) return [];
return mode === "expense"
? (selected.expenses.transactions || [])
: (selected.incomes.transactions || []);
}
const periods = mergeBucketPeriods(buckets, "full");
if (!periods.length) return [];
const full = periods[0];
return mode === "expense"
? (full.expenses.transactions || [])
: (full.incomes.transactions || []);
}
// ─── Main adapter ────────────────────────────────────────────
export function buildLatestItems(
reportData: ReportData,
selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
mode: "expense" | "income"
): LatestItem[] {
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, 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"),
}));
}

View File

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

View File

@@ -1,112 +1,44 @@
import * as React from "react";
import {
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Typography,
Box,
Button,
} from "@mui/material";
import { ReportData, GroupKey } from "../../features/report";
import { buildLatestItems } from "./LatestItems.adapter";
import LatestItemsView from "./LatestItems.view";
export interface LatestItem {
id: string | number;
icon: React.ReactNode;
iconBgColor?: string;
title: string;
subtitle: string;
amount: string;
timeAgo: string;
}
export interface LatestItemsListProps {
title?: string;
items: LatestItem[];
onViewAll?: () => void;
accentColor: any;
}
type Props = {
reportData: ReportData;
mode: "expense" | "income";
selectedPeriodId: string | null;
selectedGroupKey?: GroupKey | null;
accentColor: string;
};
export default function LatestItems({
title = "Recent Transactions",
items,
onViewAll,
reportData,
mode,
selectedPeriodId,
selectedGroupKey = null,
accentColor,
}: LatestItemsListProps) {
}: Props) {
const [visibleCount, setVisibleCount] = React.useState(5);
const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, mode);
}, [reportData, selectedPeriodId, selectedGroupKey, mode]);
const hasSelection = Boolean(selectedPeriodId) || Boolean(selectedGroupKey);
const visibleItems = React.useMemo(() => {
if (!hasSelection) return allItems.slice(0, 5);
return allItems.slice(0, visibleCount);
}, [allItems, hasSelection, visibleCount]);
const canExpand = hasSelection && visibleCount < allItems.length;
return (
<Box sx={{ width: "100%", bgcolor: "background.paper", borderRadius: 4, p: 2 }}>
{/* Header */}
<Box sx={{ display: "flex", justifyContent: "space-between", alignItems: "center", mb: 2, px: 2 }}>
<Typography variant="h6" fontWeight="bold">
{title}
</Typography>
{onViewAll && (
<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>
<LatestItemsView
items={visibleItems}
accentColor={accentColor}
canExpand={canExpand}
onExpand={() => setVisibleCount((prev) => prev + 5)}
/>
);
}

View File

@@ -1,6 +1,88 @@
import LatestItemsListView from "./LatestItems.view";
import { LatestItemsListProps } from "./LatestItems.models";
import * as React from "react";
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) {
return <LatestItemsListView {...props} />;
export default function LatestItemsView({
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>
);
}