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(
|
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],
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user