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 }}>
|
||||||
|
<Typography component="span" variant="body2">
|
||||||
{account ? `${account.name}${last4 ? ` (${last4})` : ""}` : "—"}
|
{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,40 +66,26 @@ function EntityAvatar({ entity }: { entity: ExpenseItem["entity"] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExpenseList({ items }: { items: ExpenseItem[] }) {
|
interface ExpenseCardProps {
|
||||||
const [expandedId, setExpandedId] = useState<string | null>(null);
|
item: ExpenseItem;
|
||||||
const groups = groupByMonth(items);
|
expanded: boolean;
|
||||||
|
currency: string;
|
||||||
|
onToggle: (id: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
const ExpenseCard = React.memo(function ExpenseCard({ item, expanded, currency, onToggle }: ExpenseCardProps) {
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 4 }}>
|
|
||||||
{groups.map((group) => (
|
|
||||||
<Box key={group.key}>
|
|
||||||
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.5, mb: 1.5 }}>
|
|
||||||
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
|
|
||||||
{monthLabel(group.key)}
|
|
||||||
</Typography>
|
|
||||||
<Typography variant="caption" color="text.secondary">
|
|
||||||
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
|
|
||||||
</Typography>
|
|
||||||
<Box sx={{ flex: 1 }} />
|
|
||||||
<Typography variant="body2" fontWeight={600}>
|
|
||||||
{formatCurrency(group.total, group.currency)}
|
|
||||||
</Typography>
|
|
||||||
</Box>
|
|
||||||
|
|
||||||
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
|
||||||
{group.items.map((item) => {
|
|
||||||
const negative = isExpense(item);
|
const negative = isExpense(item);
|
||||||
const currency = item.account?.currency ?? group.currency;
|
const itemCurrency = item.account?.currency ?? currency;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Accordion
|
<Accordion
|
||||||
key={item.id}
|
|
||||||
disableGutters
|
disableGutters
|
||||||
expanded={expandedId === item.id}
|
expanded={expanded}
|
||||||
onChange={(_, expanded) => setExpandedId(expanded ? item.id : null)}
|
onChange={(_, isExpanded) => onToggle(isExpanded ? item.id : "")}
|
||||||
|
TransitionProps={{ unmountOnExit: true }}
|
||||||
sx={{
|
sx={{
|
||||||
border: "1px solid",
|
border: "1px solid",
|
||||||
borderColor: expandedId === item.id ? "primary.main" : "divider",
|
borderColor: expanded ? "primary.main" : "divider",
|
||||||
borderRadius: 2,
|
borderRadius: 2,
|
||||||
overflow: "hidden",
|
overflow: "hidden",
|
||||||
boxShadow: "none",
|
boxShadow: "none",
|
||||||
@@ -154,7 +139,7 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
|
|||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{formatCurrency(item.amount, currency)}
|
{formatCurrency(item.amount, itemCurrency)}
|
||||||
</Typography>
|
</Typography>
|
||||||
</AccordionSummary>
|
</AccordionSummary>
|
||||||
<AccordionDetails sx={{ pt: 0 }}>
|
<AccordionDetails sx={{ pt: 0 }}>
|
||||||
@@ -162,7 +147,42 @@ export function ExpenseList({ items }: { items: ExpenseItem[] }) {
|
|||||||
</AccordionDetails>
|
</AccordionDetails>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
);
|
);
|
||||||
})}
|
});
|
||||||
|
|
||||||
|
export function ExpenseList({ items }: { items: ExpenseItem[] }) {
|
||||||
|
const [expandedId, setExpandedId] = useState<string | null>(null);
|
||||||
|
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 }}>
|
||||||
|
{groups.map((group) => (
|
||||||
|
<Box key={group.key}>
|
||||||
|
<Box sx={{ display: "flex", alignItems: "baseline", gap: 1.5, mb: 1.5 }}>
|
||||||
|
<Typography variant="h6" fontWeight={700} sx={{ letterSpacing: "-0.01em" }}>
|
||||||
|
{monthLabel(group.key)}
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="caption" color="text.secondary">
|
||||||
|
{group.items.length} transaction{group.items.length === 1 ? "" : "s"}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ flex: 1 }} />
|
||||||
|
<Typography variant="body2" fontWeight={600}>
|
||||||
|
{formatCurrency(group.total, group.currency)}
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{ display: "flex", flexDirection: "column", gap: 1 }}>
|
||||||
|
{group.items.map((item) => (
|
||||||
|
<ExpenseCard
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
expanded={expandedId === item.id}
|
||||||
|
currency={group.currency}
|
||||||
|
onToggle={handleToggle}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</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