diff --git a/src/Expense/Expense.tsx b/src/Expense/Expense.tsx index 8e18055..2afe060 100644 --- a/src/Expense/Expense.tsx +++ b/src/Expense/Expense.tsx @@ -54,7 +54,7 @@ export default function Expense() { const sorted = useMemo( () => - (items ?? []).sort( + [...(items ?? [])].sort( (a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), ), [items], diff --git a/src/Expense/ExpenseDetail.tsx b/src/Expense/ExpenseDetail.tsx index f851f9c..015047d 100644 --- a/src/Expense/ExpenseDetail.tsx +++ b/src/Expense/ExpenseDetail.tsx @@ -33,17 +33,19 @@ export function ExpenseDetail({ item }: { item: ExpenseItem }) { - {account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"} + + + {account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"} + {account?.type && ( )} - + } /> void; +} + +const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, onToggle }: ExpenseCardProps) { + const negative = isExpense(item); + const itemCurrency = item.account?.currency ?? currency; + + return ( + onToggle(isExpanded ? item.id : "")} + TransitionProps={{ unmountOnExit: true }} + sx={{ + border: "1px solid", + borderColor: expanded ? "primary.main" : "divider", + borderRadius: 2, + overflow: "hidden", + boxShadow: "none", + "&:before": { display: "none" }, + transition: "border-color 160ms ease, background-color 160ms ease", + "&:hover": { borderColor: "primary.light" }, + }} + > + } + sx={{ + "& .MuiAccordionSummary-content": { + alignItems: "center", + gap: 2, + minWidth: 0, + py: 0.5, + }, + }} + > + + + + {item.entity?.name ?? "Unknown"} + + + + {formatDate(item.occurred_at)} + + {item.account?.name && ( + + )} + + + + {formatCurrency(item.amount, itemCurrency)} + + + + + + + ); +}); + export function ExpenseList({ items }: { items: ExpenseItem[] }) { const [expandedId, setExpandedId] = useState(null); - const groups = groupByMonth(items); + const groups = useMemo(() => groupByMonth(items), [items]); + const handleToggle = useCallback((id: string) => { + setExpandedId((prev) => (prev === id ? null : id)); + }, []); return ( @@ -89,80 +174,15 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) { - {group.items.map((item) => { - const negative = isExpense(item); - const currency = item.account?.currency ?? group.currency; - return ( - setExpandedId(expanded ? item.id : null)} - sx={{ - border: "1px solid", - borderColor: expandedId === item.id ? "primary.main" : "divider", - borderRadius: 2, - overflow: "hidden", - boxShadow: "none", - "&:before": { display: "none" }, - transition: "border-color 160ms ease, background-color 160ms ease", - "&:hover": { borderColor: "primary.light" }, - }} - > - } - sx={{ - "& .MuiAccordionSummary-content": { - alignItems: "center", - gap: 2, - minWidth: 0, - py: 0.5, - }, - }} - > - - - - {item.entity?.name ?? "Unknown"} - - - - {formatDate(item.occurred_at)} - - {item.account?.name && ( - - )} - - - - {formatCurrency(item.amount, currency)} - - - - - - - ); - })} + {group.items.map((item) => ( + + ))} ))} diff --git a/src/Expense/types.ts b/src/Expense/types.ts index c6eeb93..0effadf 100644 --- a/src/Expense/types.ts +++ b/src/Expense/types.ts @@ -13,37 +13,53 @@ export function isExpense(item: ExpenseItem): boolean { return (item.amount ?? 0) < 0; } +const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"]; +const _currencyFormatters = new Map(); + export function formatCurrency(amount: number, currency?: string): string { - const code = currency && ["INR", "USD", "EUR", "GBP", "AED", "SGD"].includes(currency) ? currency : "INR"; - try { - return new Intl.NumberFormat("en-IN", { + const code = currency && CURRENCIES.includes(currency) ? currency : "INR"; + let formatter = _currencyFormatters.get(code); + if (!formatter) { + formatter = new Intl.NumberFormat("en-IN", { style: "currency", currency: code, maximumFractionDigits: 2, - }).format(amount); - } catch { - return `₹${amount.toFixed(2)}`; + }); + _currencyFormatters.set(code, formatter); } + return formatter.format(amount); } +const _dateCache = new Map(); + export function formatDate(iso?: string): string { if (!iso) return "—"; + const cached = _dateCache.get(iso); + if (cached) return cached; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; - return d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }); + const out = d.toLocaleDateString("en-IN", { day: "numeric", month: "short", year: "numeric" }); + _dateCache.set(iso, out); + return out; } +const _dateTimeCache = new Map(); + export function formatDateTime(iso?: string): string { if (!iso) return "—"; + const cached = _dateTimeCache.get(iso); + if (cached) return cached; const d = new Date(iso); if (Number.isNaN(d.getTime())) return "—"; - return d.toLocaleString("en-IN", { + const out = d.toLocaleString("en-IN", { day: "numeric", month: "short", year: "numeric", hour: "numeric", minute: "2-digit", }); + _dateTimeCache.set(iso, out); + return out; } export function monthKey(iso?: string): string {