From 6b4bef66ec4c9cea9840b9d79680e8278a94611c Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Tue, 27 Jan 2026 21:43:25 +0100 Subject: [PATCH] Performance optimizations: lazy rendering, row memoization, and data processing loops --- App.tsx | 2 +- components/ForecastView.tsx | 217 ++++++++++++++++++++++-------------- components/WeeklyGrid.tsx | 197 +++++++++++++++++++------------- services/dataProcessor.ts | 62 ++++++----- 4 files changed, 284 insertions(+), 194 deletions(-) diff --git a/App.tsx b/App.tsx index 22e2c05..c5867a0 100644 --- a/App.tsx +++ b/App.tsx @@ -610,7 +610,7 @@ const App: React.FC = () => { {view === 'weekly' && ( }> 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData} + data={combinedAdsData} onDrillDown={handleSkuDrillDown} stockMap={stockMap} stockFilter={filters.stock} diff --git a/components/ForecastView.tsx b/components/ForecastView.tsx index 05b8a89..a66db97 100644 --- a/components/ForecastView.tsx +++ b/components/ForecastView.tsx @@ -34,10 +34,105 @@ const Top50Badge: React.FC<{ rank: number; label: string; theme?: 'amber' | 'ind ); }; +const ForecastRow: React.FC<{ + item: ForecastViewData; + activeMonths: string[]; + top50Ranking?: { eu: Map; uk: Map }; + stockMap?: Map; +}> = React.memo(({ item, activeMonths, top50Ranking, stockMap }) => { + const asin = item.asin.toUpperCase(); + + // Top 50 Badges logic + const ranks: { rank: number; label: string; theme: 'blue' | 'indigo' }[] = []; + if (top50Ranking) { + const rankEU = top50Ranking.eu.get(asin); + const rankUK = top50Ranking.uk.get(asin); + if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' }); + if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' }); + } + + // Monthly Forecast Calculation + const filteredForecast = item.monthlyData + .filter(md => activeMonths.includes(md.month)) + .reduce((acc, md) => acc + md.forecastUnits, 0); + + // Actual Sales for 2026 (Selected Period) + const actualPeriod = item.monthlyData + .filter(md => activeMonths.includes(md.month)) + .reduce((acc, md) => acc + md.actualUnits, 0); + + // Total annual actual for achievement percentage + const actualTotal = item.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0); + const totalAchievement = item.annualForecast > 0 ? (actualTotal / item.annualForecast) * 100 : 0; + + return ( + + +
+
+ {ranks.map((r, i) => ( + + ))} + {item.sku} +
+ {item.asin} +
+
+
+ {item.title} + {stockMap && ( + + )} +
+
+ {item.line} +
+
+ + + {filteredForecast.toLocaleString('de-DE')} + + + {actualPeriod.toLocaleString('de-DE')} + + +
+ = 50 ? 'bg-emerald-500/10 text-emerald-400' : totalAchievement >= 10 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}> + {totalAchievement.toFixed(1)}% + + of Annual {item.annualForecast.toLocaleString('de-DE')} +
+ + +
+
+
= 100 ? 'bg-emerald-500' : totalAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500/50'}`} + style={{ width: `${Math.min(100, totalAchievement)}%` }} + /> +
+
+ {totalAchievement >= 100 ? ( +
+ Target Hit +
+ ) : totalAchievement > 0 ? ( +
+ {totalAchievement.toFixed(0)}% Done +
+ ) : null} +
+
+ + + ); +}); + const ForecastView: React.FC = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => { const [searchTerm, setSearchTerm] = useState(''); const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); + const [displayCount, setDisplayCount] = useState(50); // Auto-detect best Top 50 mode based on data useEffect(() => { @@ -376,92 +471,42 @@ const ForecastView: React.FC = ({ data, filters, top50Ranking - {sortedProducts.map((p) => { - const asin = p.asin.toUpperCase(); - - // Top 50 Badges logic - const ranks = []; - if (top50Ranking) { - const rankEU = top50Ranking.eu.get(asin); - const rankUK = top50Ranking.uk.get(asin); - if (rankEU) ranks.push({ rank: rankEU, label: 'EU', theme: 'indigo' as const }); - if (rankUK) ranks.push({ rank: rankUK, label: 'UK', theme: 'blue' as const }); - } - - // Monthly Forecast Calculation - const filteredForecast = p.monthlyData - .filter(md => activeMonths.includes(md.month)) - .reduce((acc, md) => acc + md.forecastUnits, 0); - - // Actual Sales for 2026 (Selected Period) - const actualPeriod = p.monthlyData - .filter(md => activeMonths.includes(md.month)) - .reduce((acc, md) => acc + md.actualUnits, 0); - - // Total annual actual for achievement percentage - const actualTotal = p.monthlyData.reduce((acc, md) => acc + md.actualUnits, 0); - const totalAchievement = p.annualForecast > 0 ? (actualTotal / p.annualForecast) * 100 : 0; - - // YTD Achievement (Achievement of the period) - const periodAchievement = filteredForecast > 0 ? (actualPeriod / filteredForecast) * 100 : 0; - - return ( - - -
- {/* ID Row */} -
- {ranks.map((r, i) => ( - - ))} - {p.sku} -
- {p.asin} -
-
- {/* Title Row */} -
- {p.title} - {stockMap && ( - - )} -
- {/* Line Row */} -
- {p.line} -
-
- - - {filteredForecast.toLocaleString('de-DE')} - - - {actualPeriod.toLocaleString('de-DE')} - - -
- = 50 ? 'bg-emerald-500/10 text-emerald-400' : totalAchievement >= 10 ? 'bg-indigo-500/10 text-indigo-400' : 'bg-slate-800 text-slate-500'}`}> - {totalAchievement.toFixed(1)}% - - of Annual {p.annualForecast.toLocaleString('de-DE')} -
- - -
-
-
= 100 ? 'bg-emerald-500' : periodAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500'}`} - style={{ width: `${Math.min(100, periodAchievement)}%` }} - >
-
- = 100 ? 'text-emerald-400' : periodAchievement >= 50 ? 'text-indigo-400' : 'text-amber-400'}`}> - {periodAchievement.toFixed(1)}% - -
- - - ); - })} + {sortedAndFiltered.length > 0 ? ( + (() => { + const displayItems = sortedAndFiltered.slice(0, displayCount); + return ( + <> + {displayItems.map(p => ( + + ))} + {displayCount < sortedAndFiltered.length && ( + + + + + + )} + + ); + })() + ) : ( + + + No products found matching your filters... + + + )}
diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index 36c53d0..a55c2a3 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -56,6 +56,95 @@ const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'bl ); }; +const WeeklyRow: React.FC<{ + row: WeeklyPivotRow; + weeks: string[]; + onDrillDown?: (sku: string) => void; + stockMap?: Map; + top50Ranking?: { eu: Map; uk: Map }; + top50Mode: 'eu' | 'uk'; + sortConfig: SortConfig; + renderGrowth: (current: number, previous: number) => React.ReactNode; +}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth }) => { + const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = []; + const asin = row.asin.trim().toUpperCase(); + if (top50Ranking) { + if (top50Mode === 'eu') { + const rank = top50Ranking.eu.get(asin); + if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' }); + } else { + const rank = top50Ranking.uk.get(asin); + if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' }); + } + } + + return ( + + +
+
+ {ranks.map((r, i) => ( + + ))} + onDrillDown?.(row.sku)} + className={`text-xs font-black uppercase tracking-tighter truncate max-w-[120px] transition-all + ${onDrillDown ? 'text-indigo-400 cursor-pointer hover:text-indigo-300 hover:underline' : 'text-indigo-400/70'}`} + title={onDrillDown ? `Click to see Ads detail for ${row.sku}` : ''} + > + {row.sku || '-'} + + {row.asin} +
+
+ {row.title} + {stockMap && ( + + )} +
+ {row.line} +
+ + {weeks.map((week, idx) => { + const val = row.unitsByWeek[week] || 0; + const prevVal = row.unitsByWeek[weeks[idx + 1]] || 0; + const spend = row.spendByWeek[week] || 0; + const prevSpend = row.spendByWeek[weeks[idx + 1]] || 0; + return ( + +
+
+ 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}> + {val > 0 ? val.toLocaleString('de-DE') : '-'} + + {val > 0 && renderGrowth(val, prevVal)} +
+
+ {spend > 0 && ( +
+ + €{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} + + {renderGrowth(spend, prevSpend)} +
+ )} + {row.gvByWeek?.[week] > 0 && ( +
+ + GV: {row.gvByWeek[week].toLocaleString('de-DE')} + + {renderGrowth(row.gvByWeek[week], row.gvByWeek?.[weeks[idx + 1]] || 0)} +
+ )} +
+
+ + ); + })} + + ); +}); + const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange }) => { // Pivot data - memoized const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); @@ -70,6 +159,8 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown const [growthThreshold, setGrowthThreshold] = useState(10); const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); + const [displayCount, setDisplayCount] = useState(50); + const scrollContainerRef = useRef(null); // Default sort: most recent week, descending, units const [sortConfig, setSortConfig] = useState(() => { @@ -492,87 +583,39 @@ const WeeklyGrid: React.FC = ({ data, top50Ranking, onDrillDown - {paginatedRows.length > 0 ? ( - paginatedRows.map((row) => { - const asin = row.asin.trim().toUpperCase(); - const ranks = []; - - if (top50Ranking) { - if (top50Mode === 'eu') { - const rank = top50Ranking.eu.get(asin); - if (rank) ranks.push({ rank, label: 'EU', theme: 'indigo' as const }); - } else { - const rank = top50Ranking.uk.get(asin); - if (rank) ranks.push({ rank, label: 'UK', theme: 'blue' as const }); - } - } - + {sortedRows.length > 0 ? ( + (() => { + const displayRows = sortedRows.slice(0, displayCount); return ( - - -
-
- {ranks.map((r, i) => ( - - ))} - onDrillDown?.(row.sku)} - className={`text-xs font-black uppercase tracking-tighter truncate max-w-[120px] transition-all - ${onDrillDown ? 'text-indigo-400 cursor-pointer hover:text-indigo-300 hover:underline' : 'text-indigo-400/70'}`} - title={onDrillDown ? `Click to see Ads detail for ${row.sku}` : ''} + <> + {displayRows.map(row => ( + + ))} + {displayCount < sortedRows.length && ( + + +
-
- {row.title} - {stockMap && ( - - )} -
- {row.line} -
- - {weeks.map((week, idx) => { - const val = row.unitsByWeek[week] || 0; - const prevVal = row.unitsByWeek[weeks[idx + 1]] || 0; - const spend = row.spendByWeek[week] || 0; - const prevSpend = row.spendByWeek[weeks[idx + 1]] || 0; - return ( - -
-
- 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}> - {val > 0 ? val.toLocaleString('de-DE') : '-'} - - {val > 0 && renderGrowth(val, prevVal)} -
-
- {spend > 0 && ( -
- - €{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} - - {renderGrowth(spend, prevSpend)} -
- )} - {row.gvByWeek?.[week] > 0 && ( -
- - GV: {row.gvByWeek[week].toLocaleString('de-DE')} - - {renderGrowth(row.gvByWeek[week], row.gvByWeek?.[weeks[idx + 1]] || 0)} -
- )} -
-
+ Load More SKUs ({sortedRows.length - displayCount} remaining) + - ); - })} - + + )} + ); - }) + })() ) : ( diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 19a4c57..8d41c7d 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -511,13 +511,14 @@ export const mergeSalesAndAdsData = ( const createKey = (asin: string, customer: string, year: number, week: number) => `${asin.trim().toUpperCase()}|${customer.trim().toUpperCase()}|${year}|${week}`; - // Build traffic lookup map + // Build traffic lookup map - Use a more efficient key const trafficMap = new Map(); if (trafficData) { - trafficData.forEach(t => { - const key = createKey(t.asin, t.country, t.year, t.week); - trafficMap.set(key, (trafficMap.get(key) || 0) + t.glanceViews); - }); + for (let i = 0; i < trafficData.length; i++) { + const t = trafficData[i]; + const key = `${t.asin.trim().toUpperCase()}|${t.country.trim().toUpperCase()}|${t.year}|${t.week}`; + trafficMap.set(key, (trafficMap.get(key) || 0) + (t.glanceViews || 0)); + } } // 1. Initialize metadata lookup map with provided global map if available, otherwise build from current sales @@ -537,23 +538,24 @@ export const mergeSalesAndAdsData = ( month: string; }>(); - salesData.forEach(sale => { + for (let i = 0; i < salesData.length; i++) { + const sale = salesData[i]; const weekNum = sale.week || 0; - if (weekNum === 0) return; + if (weekNum === 0) continue; - const key = createKey(sale.asin, sale.customer, sale.year, weekNum); + const asinUpper = sale.asin.trim().toUpperCase(); + const key = `${asinUpper}|${sale.customer.trim().toUpperCase()}|${sale.year}|${weekNum}`; // If no global map provided, build it on the fly if (!asinMetadataMap) { - const metaKey = sale.asin.trim().toUpperCase(); - const existingMeta = asinMetadata.get(metaKey); + const existingMeta = asinMetadata.get(asinUpper); if (!existingMeta || (sale.title && sale.title.length > (existingMeta.title?.length || 0))) { - asinMetadata.set(metaKey, { sku: sale.sku, title: sale.title, line: sale.line }); + asinMetadata.set(asinUpper, { sku: sale.sku, title: sale.title, line: sale.line }); } } - if (salesMap.has(key)) { - const existing = salesMap.get(key)!; + const existing = salesMap.get(key); + if (existing) { existing.sellOut += sale.sellOut; existing.units += sale.units; if (sale.title && sale.title.length > (existing.title?.length || 0)) { @@ -576,14 +578,15 @@ export const mergeSalesAndAdsData = ( month: sale.month }); } - }); + } // 3. Aggregate Ads by ASIN|Customer|Year|Week const adsMap = new Map(); - adsData.forEach(ad => { - const key = createKey(ad.asin, ad.country, ad.year, ad.week); - if (adsMap.has(key)) { - const existing = adsMap.get(key)!; + for (let i = 0; i < adsData.length; i++) { + const ad = adsData[i]; + const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`; + const existing = adsMap.get(key); + if (existing) { existing.cost += ad.cost; existing.clicks += ad.clicks; existing.impressions += ad.impressions; @@ -593,7 +596,7 @@ export const mergeSalesAndAdsData = ( } else { adsMap.set(key, { ...ad }); } - }); + } const mergedData: CombinedKPIs[] = []; const processedKeys = new Set(); @@ -1492,13 +1495,14 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): { const map = new Map(); - data.forEach(record => { - // Use ASIN as primary key since mergeSalesAndAdsData outputs one record per ASIN/week + for (let i = 0; i < data.length; i++) { + const record = data[i]; const key = record.asin || record.sku || `${record.title}-${record.line}`; - if (!key) return; + if (!key) continue; - if (!map.has(key)) { - map.set(key, { + let row = map.get(key); + if (!row) { + row = { id: key, sku: record.sku || '', title: record.title || '', @@ -1508,19 +1512,17 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): { unitsByWeek: {}, spendByWeek: {}, gvByWeek: {} - }); + }; + map.set(key, row); } - const row = map.get(key)!; if (record.week) { const weekKey = `${record.year}-${String(record.week).padStart(2, '0')}`; - row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + record.unitsTotal; - // Only add cost if we haven't already added it for this ASIN/week - // Since mergeSalesAndAdsData now outputs one record per ASIN/week, this should be clean + row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + (record.unitsTotal || 0); row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0); row.gvByWeek[weekKey] = (row.gvByWeek[weekKey] || 0) + (record.glanceViews || 0); } - }); + } return { rows: Array.from(map.values()),