mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:55:23 +02:00
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:
+67
-52
@@ -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 { CombinedKPIs } from '../types';
|
||||
import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor';
|
||||
@@ -14,11 +14,27 @@ type SortConfig = {
|
||||
} | null;
|
||||
|
||||
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 { 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 debouncedSearch = useDebounce(searchTerm, 300);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
|
||||
const [growthThreshold, setGrowthThreshold] = useState(10);
|
||||
@@ -34,26 +50,41 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
||||
// Reset pagination when search changes
|
||||
useEffect(() => {
|
||||
setCurrentPage(1);
|
||||
}, [searchTerm]);
|
||||
}, [debouncedSearch]);
|
||||
|
||||
const handleSort = (weekKey: string, metric: 'units' | 'spend') => {
|
||||
const handleSort = useCallback((weekKey: string, metric: 'units' | 'spend') => {
|
||||
setSortConfig(prev => {
|
||||
if (prev?.key === weekKey && prev.metric === metric) {
|
||||
// Toggle direction if same week & same 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 };
|
||||
});
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 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(() => {
|
||||
let result = rows;
|
||||
|
||||
// 1. Search Filter
|
||||
if (searchTerm) {
|
||||
const lowSearch = searchTerm.toLowerCase();
|
||||
// Search Filter
|
||||
if (debouncedSearch) {
|
||||
const lowSearch = debouncedSearch.toLowerCase();
|
||||
result = result.filter(r =>
|
||||
r.sku.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) {
|
||||
const currentWeek = sortConfig.key;
|
||||
const currentWeekIdx = weeks.indexOf(currentWeek);
|
||||
@@ -73,16 +104,13 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
||||
const currentUnits = r.unitsByWeek[currentWeek] || 0;
|
||||
const prevUnits = r.unitsByWeek[prevWeek] || 0;
|
||||
|
||||
// Handle edge case: units were 0 in previous week
|
||||
if (prevUnits === 0) {
|
||||
if (currentUnits === 0) return growthFilterMode === 'stable';
|
||||
return growthFilterMode === 'up'; // Gained from 0
|
||||
return growthFilterMode === 'up';
|
||||
}
|
||||
|
||||
// Calculate percentage growth
|
||||
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 === 'down') return growth <= -Math.abs(growthThreshold);
|
||||
if (growthFilterMode === 'stable') return Math.abs(growth) < growthThreshold;
|
||||
@@ -92,22 +120,23 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [rows, searchTerm, growthFilterMode, growthThreshold, sortConfig, weeks]);
|
||||
}, [rows, debouncedSearch, growthFilterMode, growthThreshold, sortConfig, weeks]);
|
||||
|
||||
// 2. Sort results
|
||||
const sortedRows = useMemo(() => {
|
||||
if (!sortConfig) return filteredRows;
|
||||
|
||||
const result = [...filteredRows];
|
||||
if (sortConfig) {
|
||||
result.sort((a, b) => {
|
||||
const metricKey = sortConfig.metric === 'units' ? 'unitsByWeek' : 'spendByWeek';
|
||||
const valA = a[metricKey][sortConfig.key] || 0;
|
||||
const valB = b[metricKey][sortConfig.key] || 0;
|
||||
if (sortConfig.direction === 'asc') {
|
||||
return valA - valB;
|
||||
}
|
||||
return valB - valA;
|
||||
});
|
||||
}
|
||||
const metricKey = sortConfig.metric === 'units' ? 'unitsByWeek' : 'spendByWeek';
|
||||
const weekKey = sortConfig.key;
|
||||
const direction = sortConfig.direction;
|
||||
|
||||
result.sort((a, b) => {
|
||||
const valA = a[metricKey][weekKey] || 0;
|
||||
const valB = b[metricKey][weekKey] || 0;
|
||||
return direction === 'asc' ? valA - valB : valB - valA;
|
||||
});
|
||||
|
||||
return result;
|
||||
}, [filteredRows, sortConfig]);
|
||||
|
||||
@@ -119,20 +148,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
||||
|
||||
const totalPages = Math.ceil(sortedRows.length / ROWS_PER_PAGE);
|
||||
|
||||
// Calculate totals per week - use unfiltered 'rows' to show complete totals
|
||||
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) => {
|
||||
const renderGrowth = useCallback((current: number, previous: number) => {
|
||||
if (!previous || previous === 0) return null;
|
||||
const pct = ((current - previous) / previous) * 100;
|
||||
const isPositive = pct >= 0;
|
||||
@@ -142,11 +158,10 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
||||
{isPositive ? '▲' : '▼'}{Math.abs(pct).toFixed(0)}%
|
||||
</span>
|
||||
);
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Export to Excel
|
||||
const handleExportExcel = () => {
|
||||
// Prepare data for export - use sortedRows to match on-screen order
|
||||
const handleExportExcel = useCallback(() => {
|
||||
const exportData = sortedRows.map(row => {
|
||||
const rowData: { [key: string]: string | number } = {
|
||||
SKU: row.sku,
|
||||
@@ -154,7 +169,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data }) => {
|
||||
Title: row.title,
|
||||
Line: row.line,
|
||||
};
|
||||
weeks.forEach(week => {
|
||||
allWeeks.forEach(week => {
|
||||
rowData[`${week} Units`] = row.unitsByWeek[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();
|
||||
XLSX.utils.book_append_sheet(wb, ws, 'Weekly Sales');
|
||||
XLSX.writeFile(wb, `Weekly_Sales_Export_${new Date().toISOString().slice(0, 10)}.xlsx`);
|
||||
};
|
||||
}, [sortedRows, allWeeks]);
|
||||
|
||||
return (
|
||||
<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">
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="flex items-center gap-1">
|
||||
<span>{weekTotals[week].units.toLocaleString('de-DE')}</span>
|
||||
{renderGrowth(weekTotals[week].units, weekTotals[weeks[idx + 1]]?.units)}
|
||||
<span>{weekTotals[week]?.units.toLocaleString('de-DE') || 0}</span>
|
||||
{renderGrowth(weekTotals[week]?.units || 0, weekTotals[weeks[idx + 1]]?.units || 0)}
|
||||
</div>
|
||||
<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>
|
||||
</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">
|
||||
<div className="flex flex-col">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
<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>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user