perf: optimize WeeklyGrid for faster loading

- Limit visible weeks to 8 (configurable MAX_VISIBLE_WEEKS)
- Add debounced search (300ms delay) to reduce re-renders
- Calculate weekTotals in single pass O(rows) instead of O(weeks × rows)
- Memoize callbacks with useCallback (handleSort, renderGrowth, handleExportExcel)
- Export still includes ALL weeks for complete data
This commit is contained in:
Christian Vidal Wolf
2026-01-22 11:44:24 +01:00
parent 17441a9b30
commit 3586e3cdd4
+67 -52
View File
@@ -1,4 +1,4 @@
import React, { useMemo, useState, useEffect } from 'react'; import React, { useMemo, useState, useEffect, useCallback } from 'react';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import { CombinedKPIs } from '../types'; import { CombinedKPIs } from '../types';
import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'; import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor';
@@ -14,11 +14,27 @@ type SortConfig = {
} | null; } | null;
const ROWS_PER_PAGE = 50; const ROWS_PER_PAGE = 50;
const MAX_VISIBLE_WEEKS = 8; // Limit visible weeks for performance
// Debounce hook for search
const useDebounce = (value: string, delay: number) => {
const [debouncedValue, setDebouncedValue] = useState(value);
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay);
return () => clearTimeout(handler);
}, [value, delay]);
return debouncedValue;
};
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => { const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
const { rows, weeks } = useMemo(() => pivotWeeklySalesData(data), [data]); // Pivot data - memoized
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
// Limit visible weeks for performance
const weeks = useMemo(() => allWeeks.slice(0, MAX_VISIBLE_WEEKS), [allWeeks]);
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const debouncedSearch = useDebounce(searchTerm, 300);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all'); const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
const [growthThreshold, setGrowthThreshold] = useState(10); const [growthThreshold, setGrowthThreshold] = useState(10);
@@ -34,26 +50,41 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
// Reset pagination when search changes // Reset pagination when search changes
useEffect(() => { useEffect(() => {
setCurrentPage(1); setCurrentPage(1);
}, [searchTerm]); }, [debouncedSearch]);
const handleSort = (weekKey: string, metric: 'units' | 'spend') => { const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend') => {
setSortConfig(prev => { setSortConfig(prev => {
if (prev?.key === weekKey && prev.metric === metric) { if (prev?.key === weekKey && prev.metric === metric) {
// Toggle direction if same week & same metric
return { key: weekKey, direction: prev.direction === 'asc' ? 'desc' : 'asc', metric }; return { key: weekKey, direction: prev.direction === 'asc' ? 'desc' : 'asc', metric };
} }
// Switch to new week/metric with default DESC
return { key: weekKey, direction: 'desc', metric }; return { key: weekKey, direction: 'desc', metric };
}); });
}; }, []);
// 1. Filter by search term // Calculate totals in a SINGLE PASS (O(rows) instead of O(weeks × rows))
const weekTotals = useMemo(() => {
const totals: { [weekKey: string]: { units: number, spend: number } } = {};
// Initialize all weeks
weeks.forEach(week => {
totals[week] = { units: 0, spend: 0 };
});
// Single pass through rows
rows.forEach(row => {
weeks.forEach(week => {
totals[week].units += (row.unitsByWeek[week] || 0);
totals[week].spend += (row.spendByWeek[week] || 0);
});
});
return totals;
}, [rows, weeks]);
// 1. Filter by search term (using debounced value)
const filteredRows = useMemo(() => { const filteredRows = useMemo(() => {
let result = rows; let result = rows;
// 1. Search Filter // Search Filter
if (searchTerm) { if (debouncedSearch) {
const lowSearch = searchTerm.toLowerCase(); const lowSearch = debouncedSearch.toLowerCase();
result = result.filter(r => result = result.filter(r =>
r.sku.toLowerCase().includes(lowSearch) || r.sku.toLowerCase().includes(lowSearch) ||
r.asin.toLowerCase().includes(lowSearch) || r.asin.toLowerCase().includes(lowSearch) ||
@@ -62,7 +93,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
); );
} }
// 2. Growth Filter (if active) // Growth Filter (if active)
if (growthFilterMode !== 'all' && sortConfig) { if (growthFilterMode !== 'all' && sortConfig) {
const currentWeek = sortConfig.key; const currentWeek = sortConfig.key;
const currentWeekIdx = weeks.indexOf(currentWeek); const currentWeekIdx = weeks.indexOf(currentWeek);
@@ -73,16 +104,13 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
const currentUnits = r.unitsByWeek[currentWeek] || 0; const currentUnits = r.unitsByWeek[currentWeek] || 0;
const prevUnits = r.unitsByWeek[prevWeek] || 0; const prevUnits = r.unitsByWeek[prevWeek] || 0;
// Handle edge case: units were 0 in previous week
if (prevUnits === 0) { if (prevUnits === 0) {
if (currentUnits === 0) return growthFilterMode === 'stable'; if (currentUnits === 0) return growthFilterMode === 'stable';
return growthFilterMode === 'up'; // Gained from 0 return growthFilterMode === 'up';
} }
// Calculate percentage growth
const growth = ((currentUnits - prevUnits) / prevUnits) * 100; const growth = ((currentUnits - prevUnits) / prevUnits) * 100;
// For 'down', we use the absolute threshold to check if it dropped BY at least that amount
if (growthFilterMode === 'up') return growth >= growthThreshold; if (growthFilterMode === 'up') return growth >= growthThreshold;
if (growthFilterMode === 'down') return growth <= -Math.abs(growthThreshold); if (growthFilterMode === 'down') return growth <= -Math.abs(growthThreshold);
if (growthFilterMode === 'stable') return Math.abs(growth) < growthThreshold; if (growthFilterMode === 'stable') return Math.abs(growth) < growthThreshold;
@@ -92,22 +120,23 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
} }
return result; return result;
}, [rows, searchTerm, growthFilterMode, growthThreshold, sortConfig, weeks]); }, [rows, debouncedSearch, growthFilterMode, growthThreshold, sortConfig, weeks]);
// 2. Sort results // 2. Sort results
const sortedRows = useMemo(() => { const sortedRows = useMemo(() => {
if (!sortConfig) return filteredRows;
const result = [...filteredRows]; const result = [...filteredRows];
if (sortConfig) { const metricKey = sortConfig.metric === 'units' ? 'unitsByWeek' : 'spendByWeek';
result.sort((a, b) => { const weekKey = sortConfig.key;
const metricKey = sortConfig.metric === 'units' ? 'unitsByWeek' : 'spendByWeek'; const direction = sortConfig.direction;
const valA = a[metricKey][sortConfig.key] || 0;
const valB = b[metricKey][sortConfig.key] || 0; result.sort((a, b) => {
if (sortConfig.direction === 'asc') { const valA = a[metricKey][weekKey] || 0;
return valA - valB; const valB = b[metricKey][weekKey] || 0;
} return direction === 'asc' ? valA - valB : valB - valA;
return valB - valA; });
});
}
return result; return result;
}, [filteredRows, sortConfig]); }, [filteredRows, sortConfig]);
@@ -119,20 +148,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
const totalPages = Math.ceil(sortedRows.length / ROWS_PER_PAGE); const totalPages = Math.ceil(sortedRows.length / ROWS_PER_PAGE);
// Calculate totals per week - use unfiltered 'rows' to show complete totals const renderGrowth = useCallback((current: number, previous: number) => {
const weekTotals = useMemo(() => {
const totals: { [weekKey: string]: { units: number, spend: number } } = {};
weeks.forEach(week => {
totals[week] = rows.reduce((acc, row) => {
acc.units += (row.unitsByWeek[week] || 0);
acc.spend += (row.spendByWeek[week] || 0);
return acc;
}, { units: 0, spend: 0 });
});
return totals;
}, [rows, weeks]);
const renderGrowth = (current: number, previous: number) => {
if (!previous || previous === 0) return null; if (!previous || previous === 0) return null;
const pct = ((current - previous) / previous) * 100; const pct = ((current - previous) / previous) * 100;
const isPositive = pct >= 0; const isPositive = pct >= 0;
@@ -142,11 +158,10 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
{isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}% {isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}%
</span> </span>
); );
}; }, []);
// Export to Excel // Export to Excel
const handleExportExcel = () => { const handleExportExcel = useCallback(() => {
// Prepare data for export - use sortedRows to match on-screen order
const exportData = sortedRows.map(row => { const exportData = sortedRows.map(row => {
const rowData: { [key: string]: string | number } = { const rowData: { [key: string]: string | number } = {
SKU: row.sku, SKU: row.sku,
@@ -154,7 +169,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
Title: row.title, Title: row.title,
Line: row.line, Line: row.line,
}; };
weeks.forEach(week => { allWeeks.forEach(week => {
rowData[`${week} Units`] = row.unitsByWeek[week] || 0; rowData[`${week} Units`] = row.unitsByWeek[week] || 0;
rowData[`${week} Spend`] = row.spendByWeek[week] || 0; rowData[`${week} Spend`] = row.spendByWeek[week] || 0;
}); });
@@ -165,7 +180,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
const wb = XLSX.utils.book_new(); const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, 'Weekly Sales'); XLSX.utils.book_append_sheet(wb, ws, 'Weekly Sales');
XLSX.writeFile(wb, `Weekly_Sales_Export_${new Date().toISOString().slice(0, 10)}.xlsx`); XLSX.writeFile(wb, `Weekly_Sales_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
}; }, [sortedRows, allWeeks]);
return ( return (
<div className="flex flex-col gap-4 animate-fade-in"> <div className="flex flex-col gap-4 animate-fade-in">
@@ -299,11 +314,11 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
<th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10"> <th key={week} className="p-3 text-sm font-black text-white text-center border-r border-white/10">
<div className="flex flex-col items-center"> <div className="flex flex-col items-center">
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span>{weekTotals[week].units.toLocaleString('de-DE')}</span> <span>{weekTotals[week]?.units.toLocaleString('de-DE') || 0}</span>
{renderGrowth(weekTotals[week].units, weekTotals[weeks[idx + 1]]?.units)} {renderGrowth(weekTotals[week]?.units || 0, weekTotals[weeks[idx + 1]]?.units || 0)}
</div> </div>
<div className="text-[10px] text-indigo-400 font-bold"> <div className="text-[10px] text-indigo-400 font-bold">
{weekTotals[week].spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} {(weekTotals[week]?.spend || 0).toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</div> </div>
</div> </div>
</th> </th>
@@ -317,7 +332,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10"> <td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
<span className="text-xs font-black text-indigo-400 uppercase tracking-tighter truncate max-w-[120px]">{row.sku}</span> <span className="text-xs font-black text-indigo-400 uppercase tracking-tighter truncate max-w-[120px]">{row.sku || '-'}</span>
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span> <span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span>
</div> </div>
<span className="text-[11px] text-white/70 truncate w-[210px] leading-tight mb-1" title={row.title}>{row.title}</span> <span className="text-[11px] text-white/70 truncate w-[210px] leading-tight mb-1" title={row.title}>{row.title}</span>
@@ -351,7 +366,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
) : ( ) : (
<tr> <tr>
<td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-base"> <td colSpan={weeks.length + 1} className="p-10 text-center text-slate-500 italic text-base">
No SKUs found matching "{searchTerm}" No SKUs found matching "{debouncedSearch}"
</td> </td>
</tr> </tr>
)} )}