fix expenses list performance + dom nesting

- memoize accordion cards and groupByMonth so toggling one
  expense re-renders O(1) cards instead of the whole list
- cache Intl.NumberFormat and date formatters per key (was
  ~2-3 instantiations per item per render)
- unmount collapsed accordion details via unmountOnExit to cut
  initial mount cost and input delay
- restructure Account row in ExpenseDetail so Chip is not nested
  inside <p> (fixes validateDOMNesting warning)
- avoid mutating expenses state array when sorting
This commit is contained in:
2026-08-17 15:22:37 +05:30
parent 9808a1f39b
commit d6856a538f
4 changed files with 128 additions and 90 deletions

View File

@@ -54,7 +54,7 @@ export default function Expense() {
const sorted = useMemo( const sorted = useMemo(
() => () =>
(items ?? []).sort( [...(items ?? [])].sort(
(a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(), (a, b) => new Date(b.occurred_at ?? 0).getTime() - new Date(a.occurred_at ?? 0).getTime(),
), ),
[items], [items],

View File

@@ -33,17 +33,19 @@ export function ExpenseDetail({ item }: { item: ExpenseItem }) {
<Row <Row
label="Account" label="Account"
value={ value={
<Typography variant="body2"> <Box sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1 }}>
{account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"} <Typography component="span" variant="body2">
{account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"}
</Typography>
{account?.type && ( {account?.type && (
<Chip <Chip
size="small" size="small"
label={account.type.replace(/_/g, " ")} label={account.type.replace(/_/g, " ")}
variant="outlined" variant="outlined"
sx={{ ml: 1.5, fontSize: 11, height: 20 }} sx={{ fontSize: 11, height: 20 }}
/> />
)} )}
</Typography> </Box>
} }
/> />
<Row <Row

View File

@@ -1,7 +1,6 @@
import React, { useState } from "react"; import React, { useCallback, useMemo, useState } from "react";
import { import {
Box, Box,
Paper,
Typography, Typography,
Accordion, Accordion,
AccordionSummary, AccordionSummary,
@@ -67,9 +66,95 @@ function EntityAvatar({ entity }: { entity: ExpenseItem["entity"] }) {
); );
} }
interface ExpenseCardProps {
item: ExpenseItem;
expanded: boolean;
currency: string;
onToggle: (id: string) => void;
}
const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, onToggle }: ExpenseCardProps) {
const negative = isExpense(item);
const itemCurrency = item.account?.currency ?? currency;
return (
<Accordion
disableGutters
expanded={expanded}
onChange={(_, isExpanded) => 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" },
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 2,
minWidth: 0,
py: 0.5,
},
}}
>
<EntityAvatar entity={item.entity} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body1"
fontWeight={600}
noWrap
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
>
{item.entity?.name ?? "Unknown"}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{formatDate(item.occurred_at)}
</Typography>
{item.account?.name && (
<Chip
size="small"
label={item.account.name}
variant="outlined"
sx={{ height: 20, fontSize: 11, "& .MuiChip-label": { px: 1 } }}
/>
)}
</Box>
</Box>
<Typography
variant="body1"
fontWeight={700}
sx={{
fontSize: "0.9375rem",
fontVariantNumeric: "tabular-nums",
color: negative ? "error.main" : "success.main",
flexShrink: 0,
}}
>
{formatCurrency(item.amount, itemCurrency)}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<ExpenseDetail item={item} />
</AccordionDetails>
</Accordion>
);
});
export function ExpenseList({ items }: { items: ExpenseItem[] }) { export function ExpenseList({ items }: { items: ExpenseItem[] }) {
const [expandedId, setExpandedId] = useState<string | null>(null); const [expandedId, setExpandedId] = useState<string | null>(null);
const groups = groupByMonth(items); const groups = useMemo(() => groupByMonth(items), [items]);
const handleToggle = useCallback((id: string) => {
setExpandedId((prev) => (prev === id ? null : id));
}, []);
return ( return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
@@ -89,80 +174,15 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
</Box> </Box>
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}> <Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
{group.items.map((item) => { {group.items.map((item) => (
const negative = isExpense(item); <ExpenseCard
const currency = item.account?.currency ?? group.currency; key={item.id}
return ( item={item}
<Accordion expanded={expandedId === item.id}
key={item.id} currency={group.currency}
disableGutters onToggle={handleToggle}
expanded={expandedId === item.id} />
onChange={(_, expanded) => 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" },
}}
>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
sx={{
"& .MuiAccordionSummary-content": {
alignItems: "center",
gap: 2,
minWidth: 0,
py: 0.5,
},
}}
>
<EntityAvatar entity={item.entity} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography
variant="body1"
fontWeight={600}
noWrap
sx={{ fontSize: "0.9375rem", lineHeight: 1.3 }}
>
{item.entity?.name ?? "Unknown"}
</Typography>
<Box sx={{ display: "flex", alignItems: "center", gap: 1, mt: 0.25 }}>
<Typography variant="caption" color="text.secondary">
{formatDate(item.occurred_at)}
</Typography>
{item.account?.name && (
<Chip
size="small"
label={item.account.name}
variant="outlined"
sx={{ height: 20, fontSize: 11, "& .MuiChip-label": { px: 1 } }}
/>
)}
</Box>
</Box>
<Typography
variant="body1"
fontWeight={700}
sx={{
fontSize: "0.9375rem",
fontVariantNumeric: "tabular-nums",
color: negative ? "error.main" : "success.main",
flexShrink: 0,
}}
>
{formatCurrency(item.amount, currency)}
</Typography>
</AccordionSummary>
<AccordionDetails sx={{ pt: 0 }}>
<ExpenseDetail item={item} />
</AccordionDetails>
</Accordion>
);
})}
</Box> </Box>
</Box> </Box>
))} ))}

View File

@@ -13,37 +13,53 @@ export function isExpense(item: ExpenseItem): boolean {
return (item.amount ?? 0) < 0; return (item.amount ?? 0) < 0;
} }
const CURRENCIES = ["INR", "USD", "EUR", "GBP", "AED", "SGD"];
const _currencyFormatters = new Map<string, Intl.NumberFormat>();
export function formatCurrency(amount: number, currency?: string): string { export function formatCurrency(amount: number, currency?: string): string {
const code = currency && ["INR", "USD", "EUR", "GBP", "AED", "SGD"].includes(currency) ? currency : "INR"; const code = currency && CURRENCIES.includes(currency) ? currency : "INR";
try { let formatter = _currencyFormatters.get(code);
return new Intl.NumberFormat("en-IN", { if (!formatter) {
formatter = new Intl.NumberFormat("en-IN", {
style: "currency", style: "currency",
currency: code, currency: code,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}).format(amount); });
} catch { _currencyFormatters.set(code, formatter);
return `${amount.toFixed(2)}`;
} }
return formatter.format(amount);
} }
const _dateCache = new Map<string, string>();
export function formatDate(iso?: string): string { export function formatDate(iso?: string): string {
if (!iso) return "—"; if (!iso) return "—";
const cached = _dateCache.get(iso);
if (cached) return cached;
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—"; 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<string, string>();
export function formatDateTime(iso?: string): string { export function formatDateTime(iso?: string): string {
if (!iso) return "—"; if (!iso) return "—";
const cached = _dateTimeCache.get(iso);
if (cached) return cached;
const d = new Date(iso); const d = new Date(iso);
if (Number.isNaN(d.getTime())) return "—"; if (Number.isNaN(d.getTime())) return "—";
return d.toLocaleString("en-IN", { const out = d.toLocaleString("en-IN", {
day: "numeric", day: "numeric",
month: "short", month: "short",
year: "numeric", year: "numeric",
hour: "numeric", hour: "numeric",
minute: "2-digit", minute: "2-digit",
}); });
_dateTimeCache.set(iso, out);
return out;
} }
export function monthKey(iso?: string): string { export function monthKey(iso?: string): string {