From 57f1d66622f5a186b379e045ad9257ded8820492 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Wed, 28 Jan 2026 11:01:05 +0100 Subject: [PATCH] Fix Weeks of Coverage: use unfiltered velocity map for robust calculation --- App.tsx | 26 ++++++++-- services/dataProcessor.ts | 101 +++++++++++++++++--------------------- 2 files changed, 66 insertions(+), 61 deletions(-) diff --git a/App.tsx b/App.tsx index 18874a6..3b42c75 100644 --- a/App.tsx +++ b/App.tsx @@ -5,8 +5,8 @@ import Dashboard from './components/Dashboard'; import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; +import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, calculateVelocityMap, getUniqueValues, processForecastExcel } from './services/dataProcessor'; import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData } from './types'; -import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData, processForecastExcel, calculateForecastViewData, processStockExcel, processVendorStockExcel } from './services/dataProcessor'; import { queryGemini } from './services/geminiService'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage'; @@ -198,13 +198,13 @@ const App: React.FC = () => { } }); - const viewData = calculateForecastViewData(sales, fcRecords, meta, activeFilters); + const viewData = calculateForecastViewData(sales, fcRecords, meta, activeFilters, velocityMap); setForecastData(viewData); console.log('[App] Forecast loaded:', viewData.length, 'records'); } catch (error) { console.warn("Forecast fetch failed:", error); } - }, []); + }, [velocityMap]); // Added velocityMap to dependencies // Update forecast when filters change useEffect(() => { @@ -450,10 +450,26 @@ const App: React.FC = () => { }; }, [rawData]); + // Calculate 4-week Sales Velocity Map (Context-Aware) + const velocityMap = useMemo(() => { + // Determine which dataset to use for velocity calculation based on top50Mode (which reflects region filter) + // If 'uk', use UK data. If 'eu', use EU data. + // IMPOTANT: We do NOT filter by Week/Month here, so we get the full history for velocity calculation + const regionData = rawData.filter(r => { + if (top50Mode === 'uk') return r.customer.toLowerCase().includes('uk'); + // for EU, exclude UK + return !r.customer.toLowerCase().includes('uk'); + }); + return calculateVelocityMap(regionData); + }, [rawData, top50Mode]); + // Combine Sales & Ads Data dynamically based on current filters const combinedAdsData = useMemo(() => { - return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData); - }, [filteredData, filteredAdsData, globalAsinMetadata, trafficData]); + return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap); + }, [filteredData, filteredAdsData, globalAsinMetadata, trafficData, velocityMap]); + + // Derived Data for Views + const years = useMemo(() => getUniqueValues(rawData, 'year').sort().reverse(), [rawData]); // Derive Context Data (Product Line Context when drilling down) const contextAggregatedData = useMemo(() => { diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 1953562..cb501eb 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -505,7 +505,8 @@ export const mergeSalesAndAdsData = ( salesData: SalesRecord[], adsData: AdsRecord[], asinMetadataMap?: Map, - trafficData?: TrafficRecord[] + trafficData?: TrafficRecord[], + velocityMap?: Map ): CombinedKPIs[] => { // Key for both sales and ads: ASIN|Customer|Year|Week const createKey = (asin: string, customer: string, year: number, week: number) => @@ -598,35 +599,10 @@ export const mergeSalesAndAdsData = ( } } - // 4. Calculate 4-Week Average Sales per ASIN - const latestYear = Math.max(...salesData.map(r => r.year).filter(y => y > 0)); - const yearData = salesData.filter(r => r.year === latestYear); - const latestWeek = yearData.length > 0 ? Math.max(...yearData.map(r => r.week).filter(w => w !== undefined) as number[]) : 0; - - const last4WeeksKeys = new Set(); - for (let i = 0; i < 4; i++) { - let w = latestWeek - i; - let y = latestYear; - if (w <= 0) { - w = 52 + w; - y = latestYear - 1; - } - last4WeeksKeys.add(`${y}|${w}`); - } - - const asin4WeekSales = new Map(); - salesData.forEach(r => { - if (r.week === undefined) return; - if (last4WeeksKeys.has(`${r.year}|${r.week}`)) { - const key = r.asin.trim().toUpperCase(); - asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + r.units); - } - }); - const mergedData: CombinedKPIs[] = []; const processedKeys = new Set(); - // 5. Create ONE record per ASIN/Customer/Year/Week from sales + // 4. Create ONE record per ASIN/Customer/Year/Week from sales salesMap.forEach((sale, key) => { processedKeys.add(key); const ad = adsMap.get(key); @@ -648,7 +624,7 @@ export const mergeSalesAndAdsData = ( const ctr = adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0; const cpc = adClicks > 0 ? adCost / adClicks : 0; const cvrUnits = adClicks > 0 ? (adUnits / adClicks) * 100 : 0; - const avgWeeklySales = (asin4WeekSales.get(sale.asin.trim().toUpperCase()) || 0) / 4; + const avgWeeklySales = velocityMap?.get(sale.asin.trim().toUpperCase()) || 0; mergedData.push({ id: `merged-${key}`, @@ -689,7 +665,7 @@ export const mergeSalesAndAdsData = ( if (!processedKeys.has(key)) { const asin = ad.asin.trim().toUpperCase(); const meta = asinMetadata.get(asin); - const avgWeeklySales = (asin4WeekSales.get(asin) || 0) / 4; + const avgWeeklySales = velocityMap?.get(asin) || 0; mergedData.push({ id: `ads-only-${key}`, @@ -1642,7 +1618,8 @@ export const calculateForecastViewData = ( rawData: SalesRecord[], forecastData: ForecastRecord[], asinMetadata: Map, - filters?: FilterState + filters?: FilterState, + velocityMap?: Map ): ProductForecastData[] => { const data2025 = rawData.filter(r => r.year === 2025); const data2026 = rawData.filter(r => r.year === 2026); @@ -1713,35 +1690,10 @@ export const calculateForecastViewData = ( monthMap.set(m, (monthMap.get(m) || 0) + r.units); }); - // 4-Week Average Sales Calculation - const latestYear = Math.max(...rawData.map(r => r.year).filter(y => y > 0)); - const yearData = rawData.filter(r => r.year === latestYear); - const latestWeek = yearData.length > 0 ? Math.max(...yearData.map(r => r.week).filter(w => w !== undefined) as number[]) : 0; - - const last4WeeksKeys = new Set(); - for (let i = 0; i < 4; i++) { - let w = latestWeek - i; - let y = latestYear; - if (w <= 0) { - w = 52 + w; - y = latestYear - 1; - } - last4WeeksKeys.add(`${y}|${w}`); - } - - const asin4WeekSales = new Map(); - rawData.forEach(r => { - if (r.week === undefined) return; - if (last4WeeksKeys.has(`${r.year}|${r.week}`)) { - const key = r.asin.trim().toUpperCase(); - asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + r.units); - } - }); - return forecastData.map(fc => { const identifier = fc.asin.toUpperCase(); const meta = asinMetadata.get(identifier); - const avgWeeklySales = (asin4WeekSales.get(identifier) || 0) / 4; + const avgWeeklySales = velocityMap?.get(identifier) || 0; // 2. Determine weights for this ASIN const productRecords2025 = dataByAsin2025.get(identifier) || []; @@ -1858,6 +1810,43 @@ export const processVendorStockExcel = async (fileOrBuffer: File | ArrayBuffer): } }; +export const calculateVelocityMap = (data: SalesRecord[]): Map => { + // 4-Week Average Sales Calculation + const validYears = data.map(r => r.year).filter(y => y > 0); + if (validYears.length === 0) return new Map(); + + const latestYear = Math.max(...validYears); + const yearData = data.filter(r => r.year === latestYear); + const latestWeek = yearData.length > 0 ? Math.max(...yearData.map(r => r.week).filter(w => w !== undefined) as number[]) : 0; + + const last4WeeksKeys = new Set(); + for (let i = 0; i < 4; i++) { + let w = latestWeek - i; + let y = latestYear; + if (w <= 0) { + w = 52 + w; + y = latestYear - 1; + } + last4WeeksKeys.add(`${y}|${w}`); + } + + const asin4WeekSales = new Map(); + data.forEach(r => { + if (r.week === undefined) return; + if (last4WeeksKeys.has(`${r.year}|${r.week}`)) { + const key = r.asin.trim().toUpperCase(); + asin4WeekSales.set(key, (asin4WeekSales.get(key) || 0) + r.units); + } + }); + + const velocityMap = new Map(); + asin4WeekSales.forEach((total, asin) => { + velocityMap.set(asin, total / 4); + }); + + return velocityMap; +}; + export const processStockExcel = async (fileOrBuffer: File | ArrayBuffer): Promise> => { try { const arrayBuffer = fileOrBuffer instanceof File