Performance optimizations: lazy rendering, row memoization, and data processing loops

This commit is contained in:
Christian Vidal Wolf
2026-01-27 21:43:25 +01:00
parent f521dd2921
commit 6b4bef66ec
4 changed files with 284 additions and 194 deletions
+1 -1
View File
@@ -610,7 +610,7 @@ const App: React.FC = () => {
{view === 'weekly' && ( {view === 'weekly' && (
<Suspense fallback={<LoadingSpinner />}> <Suspense fallback={<LoadingSpinner />}>
<WeeklyGrid <WeeklyGrid
data={filteredAdsData.length > 0 ? mergeSalesAndAdsData(filteredData, filteredAdsData, undefined, trafficData) : filteredData} data={combinedAdsData}
onDrillDown={handleSkuDrillDown} onDrillDown={handleSkuDrillDown}
stockMap={stockMap} stockMap={stockMap}
stockFilter={filters.stock} stockFilter={filters.stock}
+126 -81
View File
@@ -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<string, number>; uk: Map<string, number> };
stockMap?: Map<string, number>;
}> = 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 (
<tr key={item.asin} className="hover:bg-indigo-500/5 transition-colors group">
<td className="px-6 py-4">
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-3">
{ranks.map((r, i) => (
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
))}
<span className="font-black text-sm text-[#818cf8] uppercase tracking-tight">{item.sku}</span>
<div className="px-2 py-0.5 rounded bg-slate-800/80 border border-white/5 text-[10px] font-bold text-slate-400 uppercase tracking-wider">
{item.asin}
</div>
</div>
<div className="flex items-start gap-2 text-[13px] font-medium text-slate-100 leading-snug group-hover:text-white transition-colors">
<span className="truncate max-w-[170px]">{item.title}</span>
{stockMap && (
<StockBadge stock={stockMap.get(item.sku?.replace(/(DE|EN)$/i, ''))} />
)}
</div>
<div className="text-[10px] font-black text-[#ec4899] uppercase tracking-[0.15em]">
{item.line}
</div>
</div>
</td>
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
{filteredForecast.toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
{actualPeriod.toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex flex-col items-end gap-1">
<span className={`px-2 py-1 rounded text-xs font-black ${totalAchievement >= 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)}%
</span>
<span className="text-[9px] text-slate-500 font-bold uppercase">of Annual {item.annualForecast.toLocaleString('de-DE')}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col items-center gap-2">
<div className="w-24 h-1.5 bg-slate-800 rounded-full overflow-hidden border border-white/5">
<div
className={`h-full transition-all duration-500 rounded-full ${totalAchievement >= 100 ? 'bg-emerald-500' : totalAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500/50'}`}
style={{ width: `${Math.min(100, totalAchievement)}%` }}
/>
</div>
<div className="flex items-center gap-1.5">
{totalAchievement >= 100 ? (
<div className="px-1.5 py-0.5 rounded text-[8px] font-black bg-emerald-500/20 text-emerald-400 border border-emerald-500/30 uppercase tracking-tighter animate-pulse">
Target Hit
</div>
) : totalAchievement > 0 ? (
<div className="text-[9px] font-bold text-slate-500 italic">
{totalAchievement.toFixed(0)}% Done
</div>
) : null}
</div>
</div>
</td>
</tr>
);
});
const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => { const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking, stockMap, stockFilter, onStockFilterChange }) => {
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState('');
const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
const [displayCount, setDisplayCount] = useState(50);
// Auto-detect best Top 50 mode based on data // Auto-detect best Top 50 mode based on data
useEffect(() => { useEffect(() => {
@@ -376,92 +471,42 @@ const ForecastView: React.FC<ForecastViewProps> = ({ data, filters, top50Ranking
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-800"> <tbody className="divide-y divide-slate-800">
{sortedProducts.map((p) => { {sortedAndFiltered.length > 0 ? (
const asin = p.asin.toUpperCase(); (() => {
const displayItems = sortedAndFiltered.slice(0, displayCount);
// 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 ( return (
<tr key={p.asin} className="hover:bg-indigo-500/5 transition-colors group"> <>
<td className="px-6 py-4"> {displayItems.map(p => (
<div className="flex flex-col gap-1.5"> <ForecastRow
{/* ID Row */} key={p.asin}
<div className="flex items-center gap-3"> item={p}
{ranks.map((r, i) => ( activeMonths={activeMonths}
<Top50Badge key={i} rank={r.rank} label={r.label} /> top50Ranking={top50Ranking}
stockMap={stockMap}
/>
))} ))}
<span className="font-black text-sm text-[#818cf8] uppercase tracking-tight">{p.sku}</span> {displayCount < sortedAndFiltered.length && (
<div className="px-2 py-0.5 rounded bg-slate-800/80 border border-white/5 text-[10px] font-bold text-slate-400 uppercase tracking-wider"> <tr>
{p.asin} <td colSpan={5} className="px-6 py-6 text-center bg-slate-900/40 backdrop-blur-sm border-t border-white/5">
</div> <button
</div> onClick={() => setDisplayCount(prev => prev + 100)}
{/* Title Row */} className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest shadow-xl transition-all active:scale-95 border border-indigo-400/30"
<div className="flex items-start gap-2 text-[13px] font-medium text-slate-100 leading-snug group-hover:text-white transition-colors"> >
<span className="truncate max-w-[170px]">{p.title}</span> Load More SKUs ({sortedAndFiltered.length - displayCount} remaining)
{stockMap && ( </button>
<StockBadge stock={stockMap.get(p.sku?.replace(/(DE|EN)$/i, ''))} />
)}
</div>
{/* Line Row */}
<div className="text-[10px] font-black text-[#ec4899] uppercase tracking-[0.15em]">
{p.line}
</div>
</div>
</td>
<td className="px-6 py-4 text-right font-mono font-bold text-slate-400">
{filteredForecast.toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 text-right font-mono font-bold text-emerald-400">
{actualPeriod.toLocaleString('de-DE')}
</td>
<td className="px-6 py-4 text-right">
<div className="flex flex-col items-end gap-1">
<span className={`px-2 py-1 rounded text-xs font-black ${totalAchievement >= 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)}%
</span>
<span className="text-[9px] text-slate-500 font-bold uppercase">of Annual {p.annualForecast.toLocaleString('de-DE')}</span>
</div>
</td>
<td className="px-6 py-4">
<div className="flex flex-col items-center gap-2">
<div className="w-32 h-2 bg-slate-800 rounded-full overflow-hidden border border-white/5">
<div
className={`h-full transition-all duration-1000 ${periodAchievement >= 100 ? 'bg-emerald-500' : periodAchievement >= 50 ? 'bg-indigo-500' : 'bg-amber-500'}`}
style={{ width: `${Math.min(100, periodAchievement)}%` }}
></div>
</div>
<span className={`text-[11px] font-black ${periodAchievement >= 100 ? 'text-emerald-400' : periodAchievement >= 50 ? 'text-indigo-400' : 'text-amber-400'}`}>
{periodAchievement.toFixed(1)}%
</span>
</div>
</td> </td>
</tr> </tr>
)}
</>
); );
})} })()
) : (
<tr>
<td colSpan={5} className="px-6 py-10 text-center text-slate-500 italic">
No products found matching your filters...
</td>
</tr>
)}
</tbody> </tbody>
</table> </table>
</div> </div>
+118 -75
View File
@@ -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<string, number>;
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
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 (
<tr className="hover:bg-white/[0.02] transition-colors group">
<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">
{ranks.map((r, i) => (
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
))}
<span
onClick={() => 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 || '-'}
</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 className="flex items-start gap-2 mb-1">
<span className="text-[11px] text-white/70 truncate w-[190px] leading-tight" title={row.title}>{row.title}</span>
{stockMap && (
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
)}
</div>
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
</div>
</td>
{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 (
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
<div className="flex flex-col items-center justify-center gap-0.5">
<div className="flex items-center gap-1">
<span className={`text-sm font-bold ${val > 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}>
{val > 0 ? val.toLocaleString('de-DE') : '-'}
</span>
{val > 0 && renderGrowth(val, prevVal)}
</div>
<div className="flex flex-col items-center">
{spend > 0 && (
<div className="flex items-center gap-1">
<span className={`text-[11px] font-medium ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-300' : 'text-indigo-400/80'}`}>
{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</span>
{renderGrowth(spend, prevSpend)}
</div>
)}
{row.gvByWeek?.[week] > 0 && (
<div className="flex items-center gap-1">
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
GV: {row.gvByWeek[week].toLocaleString('de-DE')}
</span>
{renderGrowth(row.gvByWeek[week], row.gvByWeek?.[weeks[idx + 1]] || 0)}
</div>
)}
</div>
</div>
</td>
);
})}
</tr>
);
});
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange }) => { const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown, stockMap, stockFilter, onStockFilterChange }) => {
// Pivot data - memoized // Pivot data - memoized
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]); const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
@@ -70,6 +159,8 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
const [growthThreshold, setGrowthThreshold] = useState(10); const [growthThreshold, setGrowthThreshold] = useState(10);
const [showOnlyTop50, setShowOnlyTop50] = useState(false); const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu'); const [top50Mode, setTop50Mode] = useState<'eu' | 'uk'>('eu');
const [displayCount, setDisplayCount] = useState(50);
const scrollContainerRef = useRef<HTMLDivElement>(null);
// Default sort: most recent week, descending, units // Default sort: most recent week, descending, units
const [sortConfig, setSortConfig] = useState<SortConfig>(() => { const [sortConfig, setSortConfig] = useState<SortConfig>(() => {
@@ -492,87 +583,39 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking, onDrillDown
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
{paginatedRows.length > 0 ? ( {sortedRows.length > 0 ? (
paginatedRows.map((row) => { (() => {
const asin = row.asin.trim().toUpperCase(); const displayRows = sortedRows.slice(0, displayCount);
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 });
}
}
return ( return (
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group"> <>
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10"> {displayRows.map(row => (
<div className="flex flex-col"> <WeeklyRow
<div className="flex items-center gap-2 mb-0.5"> key={row.id}
{ranks.map((r, i) => ( row={row}
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} /> weeks={weeks}
onDrillDown={onDrillDown}
stockMap={stockMap}
top50Ranking={top50Ranking}
top50Mode={top50Mode}
sortConfig={sortConfig}
renderGrowth={renderGrowth}
/>
))} ))}
<span {displayCount < sortedRows.length && (
onClick={() => onDrillDown?.(row.sku)} <tr>
className={`text-xs font-black uppercase tracking-tighter truncate max-w-[120px] transition-all <td colSpan={weeks.length + 1} className="p-6 text-center bg-slate-900/50 backdrop-blur-sm border-t border-white/5">
${onDrillDown ? 'text-indigo-400 cursor-pointer hover:text-indigo-300 hover:underline' : 'text-indigo-400/70'}`} <button
title={onDrillDown ? `Click to see Ads detail for ${row.sku}` : ''} onClick={() => setDisplayCount(prev => prev + 100)}
className="px-6 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white rounded-xl text-xs font-black uppercase tracking-widest shadow-xl transition-all active:scale-95 border border-indigo-400/30"
> >
{row.sku || '-'} Load More SKUs ({sortedRows.length - displayCount} remaining)
</span> </button>
<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 className="flex items-start gap-2 mb-1">
<span className="text-[11px] text-white/70 truncate w-[190px] leading-tight" title={row.title}>{row.title}</span>
{stockMap && (
<StockBadge stock={stockMap.get(row.sku?.replace(/(DE|EN)$/i, ''))} />
)}
</div>
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
</div>
</td> </td>
{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 (
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
<div className="flex flex-col items-center justify-center gap-0.5">
<div className="flex items-center gap-1">
<span className={`text-sm font-bold ${val > 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}>
{val > 0 ? val.toLocaleString('de-DE') : '-'}
</span>
{val > 0 && renderGrowth(val, prevVal)}
</div>
<div className="flex flex-col items-center">
{spend > 0 && (
<div className="flex items-center gap-1">
<span className={`text-[11px] font-medium ${sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-300' : 'text-indigo-400/80'}`}>
{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}
</span>
{renderGrowth(spend, prevSpend)}
</div>
)}
{row.gvByWeek?.[week] > 0 && (
<div className="flex items-center gap-1">
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
GV: {row.gvByWeek[week].toLocaleString('de-DE')}
</span>
{renderGrowth(row.gvByWeek[week], row.gvByWeek?.[weeks[idx + 1]] || 0)}
</div>
)}
</div>
</div>
</td>
);
})}
</tr> </tr>
)}
</>
); );
}) })()
) : ( ) : (
<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">
+32 -30
View File
@@ -511,13 +511,14 @@ export const mergeSalesAndAdsData = (
const createKey = (asin: string, customer: string, year: number, week: number) => const createKey = (asin: string, customer: string, year: number, week: number) =>
`${asin.trim().toUpperCase()}|${customer.trim().toUpperCase()}|${year}|${week}`; `${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<string, number>(); const trafficMap = new Map<string, number>();
if (trafficData) { if (trafficData) {
trafficData.forEach(t => { for (let i = 0; i < trafficData.length; i++) {
const key = createKey(t.asin, t.country, t.year, t.week); const t = trafficData[i];
trafficMap.set(key, (trafficMap.get(key) || 0) + t.glanceViews); 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 // 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; month: string;
}>(); }>();
salesData.forEach(sale => { for (let i = 0; i < salesData.length; i++) {
const sale = salesData[i];
const weekNum = sale.week || 0; 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 no global map provided, build it on the fly
if (!asinMetadataMap) { if (!asinMetadataMap) {
const metaKey = sale.asin.trim().toUpperCase(); const existingMeta = asinMetadata.get(asinUpper);
const existingMeta = asinMetadata.get(metaKey);
if (!existingMeta || (sale.title && sale.title.length > (existingMeta.title?.length || 0))) { 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.sellOut += sale.sellOut;
existing.units += sale.units; existing.units += sale.units;
if (sale.title && sale.title.length > (existing.title?.length || 0)) { if (sale.title && sale.title.length > (existing.title?.length || 0)) {
@@ -576,14 +578,15 @@ export const mergeSalesAndAdsData = (
month: sale.month month: sale.month
}); });
} }
}); }
// 3. Aggregate Ads by ASIN|Customer|Year|Week // 3. Aggregate Ads by ASIN|Customer|Year|Week
const adsMap = new Map<string, AdsRecord>(); const adsMap = new Map<string, AdsRecord>();
adsData.forEach(ad => { for (let i = 0; i < adsData.length; i++) {
const key = createKey(ad.asin, ad.country, ad.year, ad.week); const ad = adsData[i];
if (adsMap.has(key)) { const key = `${ad.asin.trim().toUpperCase()}|${ad.country.trim().toUpperCase()}|${ad.year}|${ad.week}`;
const existing = adsMap.get(key)!; const existing = adsMap.get(key);
if (existing) {
existing.cost += ad.cost; existing.cost += ad.cost;
existing.clicks += ad.clicks; existing.clicks += ad.clicks;
existing.impressions += ad.impressions; existing.impressions += ad.impressions;
@@ -593,7 +596,7 @@ export const mergeSalesAndAdsData = (
} else { } else {
adsMap.set(key, { ...ad }); adsMap.set(key, { ...ad });
} }
}); }
const mergedData: CombinedKPIs[] = []; const mergedData: CombinedKPIs[] = [];
const processedKeys = new Set<string>(); const processedKeys = new Set<string>();
@@ -1492,13 +1495,14 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
const map = new Map<string, WeeklyPivotRow>(); const map = new Map<string, WeeklyPivotRow>();
data.forEach(record => { for (let i = 0; i < data.length; i++) {
// Use ASIN as primary key since mergeSalesAndAdsData outputs one record per ASIN/week const record = data[i];
const key = record.asin || record.sku || `${record.title}-${record.line}`; const key = record.asin || record.sku || `${record.title}-${record.line}`;
if (!key) return; if (!key) continue;
if (!map.has(key)) { let row = map.get(key);
map.set(key, { if (!row) {
row = {
id: key, id: key,
sku: record.sku || '', sku: record.sku || '',
title: record.title || '', title: record.title || '',
@@ -1508,19 +1512,17 @@ export const pivotWeeklySalesData = (data: CombinedKPIs[]): {
unitsByWeek: {}, unitsByWeek: {},
spendByWeek: {}, spendByWeek: {},
gvByWeek: {} gvByWeek: {}
}); };
map.set(key, row);
} }
const row = map.get(key)!;
if (record.week) { if (record.week) {
const weekKey = `${record.year}-${String(record.week).padStart(2, '0')}`; const weekKey = `${record.year}-${String(record.week).padStart(2, '0')}`;
row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + record.unitsTotal; row.unitsByWeek[weekKey] = (row.unitsByWeek[weekKey] || 0) + (record.unitsTotal || 0);
// 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.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0); row.spendByWeek[weekKey] = (row.spendByWeek[weekKey] || 0) + (record.cost || 0);
row.gvByWeek[weekKey] = (row.gvByWeek[weekKey] || 0) + (record.glanceViews || 0); row.gvByWeek[weekKey] = (row.gvByWeek[weekKey] || 0) + (record.glanceViews || 0);
} }
}); }
return { return {
rows: Array.from(map.values()), rows: Array.from(map.values()),