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:
@@ -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],
|
||||
|
||||
@@ -33,17 +33,19 @@ export function ExpenseDetail({ item }: { item: ExpenseItem }) {
|
||||
<Row
|
||||
label="Account"
|
||||
value={
|
||||
<Typography variant="body2">
|
||||
{account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"}
|
||||
<Box sx={{ display: "flex", alignItems: "center", flexWrap: "wrap", gap: 1 }}>
|
||||
<Typography component="span" variant="body2">
|
||||
{account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"}
|
||||
</Typography>
|
||||
{account?.type && (
|
||||
<Chip
|
||||
size="small"
|
||||
label={account.type.replace(/_/g, " ")}
|
||||
variant="outlined"
|
||||
sx={{ ml: 1.5, fontSize: 11, height: 20 }}
|
||||
sx={{ fontSize: 11, height: 20 }}
|
||||
/>
|
||||
)}
|
||||
</Typography>
|
||||
</Box>
|
||||
}
|
||||
/>
|
||||
<Row
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState } from "react";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Paper,
|
||||
Typography,
|
||||
Accordion,
|
||||
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[] }) {
|
||||
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 (
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
||||
@@ -89,80 +174,15 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
|
||||
</Box>
|
||||
|
||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||
{group.items.map((item) => {
|
||||
const negative = isExpense(item);
|
||||
const currency = item.account?.currency ?? group.currency;
|
||||
return (
|
||||
<Accordion
|
||||
key={item.id}
|
||||
disableGutters
|
||||
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>
|
||||
);
|
||||
})}
|
||||
{group.items.map((item) => (
|
||||
<ExpenseCard
|
||||
key={item.id}
|
||||
item={item}
|
||||
expanded={expandedId === item.id}
|
||||
currency={group.currency}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</Box>
|
||||
))}
|
||||
|
||||
@@ -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<string, Intl.NumberFormat>();
|
||||
|
||||
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<string, string>();
|
||||
|
||||
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<string, string>();
|
||||
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user