diff --git a/components/MetricDetailTooltip.tsx b/components/MetricDetailTooltip.tsx new file mode 100644 index 0000000..a852a8a --- /dev/null +++ b/components/MetricDetailTooltip.tsx @@ -0,0 +1,126 @@ +import React, { useState } from 'react'; + +interface MetricDetailTooltipProps { + children: React.ReactNode; + currentValue: number; + previousValue: number; + yoyValue: number; + currentWeekLabel: string; + previousWeekLabel: string; + yoyWeekLabel: string; + metricName: string; + metricColor: string; + formatValue?: (val: number) => string; + experimentDelta?: number | null; + baselineValue?: number | null; +} + +/** + * Tooltip component to show comparison details on hover (WoW and YoY). + * Extracted from WeeklyGrid to prevent circular dependencies and initialization errors. + */ +export const MetricDetailTooltip: React.FC = ({ + children, + currentValue, + previousValue, + yoyValue, + currentWeekLabel, + previousWeekLabel, + yoyWeekLabel, + metricName, + metricColor, + formatValue, + experimentDelta, + baselineValue +}) => { + const [isVisible, setIsVisible] = useState(false); + + const wowGrowth = previousValue > 0 ? ((currentValue - previousValue) / previousValue) * 100 : (currentValue > 0 ? 100 : 0); + const yoyGrowth = yoyValue > 0 ? ((currentValue - yoyValue) / yoyValue) * 100 : null; + const format = formatValue || ((v: number) => v.toLocaleString('de-DE')); + const hasYoyData = yoyValue > 0; + + return ( +
setIsVisible(true)} + onMouseLeave={() => setIsVisible(false)} + > +
{children}
+ {isVisible && ( +
+
+ 📊 {metricName} Comparison +
+ +
+ {/* Current Week */} +
+ {currentWeekLabel} + {format(currentValue)} +
+ + {/* Previous Week */} +
+ {previousWeekLabel} + {previousValue > 0 ? format(previousValue) : 'N/A'} +
+ + {/* Same Week Last Year */} +
+ {yoyWeekLabel} + + {hasYoyData ? format(yoyValue) : 'No data'} + +
+ + {/* WoW Growth */} +
+
+ vs Previous Week + {previousValue > 0 ? ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {wowGrowth >= 0 ? 'â–²' : 'â–¼'} {Math.abs(wowGrowth).toFixed(1)}% + + ) : ( + N/A + )} +
+
+ + {/* YoY Growth */} +
+ vs Same Week Last Year + {yoyGrowth !== null ? ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {yoyGrowth >= 0 ? 'â–²' : 'â–¼'} {Math.abs(yoyGrowth).toFixed(1)}% + + ) : ( + No data + )} +
+ + {/* Experiment Baseline Delta (If active) */} + {typeof experimentDelta === 'number' && typeof baselineValue === 'number' && ( +
+
+ vs Pre-Experiment Baseline + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {experimentDelta >= 0 ? 'â–²' : 'â–¼'} {Math.abs(experimentDelta).toFixed(1)}% + +
+
+ Baseline Avg (4w) + {format(baselineValue)} +
+
+ )} +
+ + {/* Arrow */} +
+
+ )} +
+ ); +}; diff --git a/components/VendorStockBadge.tsx b/components/VendorStockBadge.tsx index 1a34486..3988b51 100644 --- a/components/VendorStockBadge.tsx +++ b/components/VendorStockBadge.tsx @@ -11,16 +11,17 @@ interface VendorStockBadgeProps { export const VendorStockBadge: React.FC = ({ asin, vendorStockMap, mode, avgWeeklySales }) => { if (!asin) return null; - const stockData = vendorStockMap?.get(asin.toUpperCase()); + const normalizedAsin = asin.trim().toUpperCase(); + const stockData = vendorStockMap?.get(normalizedAsin); if (!stockData) { return ( -
+
No Data
- - + -
); diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index a6b2e12..8dac6fd 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -1,16 +1,4 @@ -import React, { useMemo, useState, useEffect, useCallback, useRef } from 'react'; -import * as XLSX from 'xlsx'; -import { CombinedKPIs, ColumnFilterCondition, SalesRecord } from '../types'; -import { pivotWeeklySalesData, WeeklyPivotRow, PAN_EU_COUNTRIES, checkNumericConditions, filterData } from '../services/dataProcessor'; -import { StockBadge } from './StockBadge'; -import { InColumnStockFilter } from './InColumnStockFilter'; -import { NumericColumnFilter, NumericFilterConfig, passesNumericFilter } from './NumericColumnFilter'; -import { Top50Badge } from './Top50Badge'; -import { VendorStockBadge } from './VendorStockBadge'; -import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; -import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons'; -import { ExcelFilter } from './ExcelFilter'; -import { ExperimentBadge } from './ExperimentBadge'; +import { WeeklyRow } from './WeeklyRow'; import { ActiveExperiment } from '../types'; interface WeeklyGridProps { @@ -55,398 +43,8 @@ const useDebounce = (value: string, delay: number) => { return debouncedValue; }; -// Tooltip component to show comparison details on hover (WoW and YoY) -const MetricDetailTooltip: React.FC<{ - children: React.ReactNode; - currentValue: number; - previousValue: number; - yoyValue: number; - currentWeekLabel: string; - previousWeekLabel: string; - yoyWeekLabel: string; - metricName: string; - metricColor: string; - formatValue?: (val: number) => string; - experimentDelta?: number | null; - baselineValue?: number | null; -}> = ({ children, currentValue, previousValue, yoyValue, currentWeekLabel, previousWeekLabel, yoyWeekLabel, metricName, metricColor, formatValue, experimentDelta, baselineValue }) => { - const [isVisible, setIsVisible] = useState(false); - - const wowGrowth = previousValue > 0 ? ((currentValue - previousValue) / previousValue) * 100 : (currentValue > 0 ? 100 : 0); - const yoyGrowth = yoyValue > 0 ? ((currentValue - yoyValue) / yoyValue) * 100 : null; - const format = formatValue || ((v: number) => v.toLocaleString('de-DE')); - const hasYoyData = yoyValue > 0; - - return ( -
setIsVisible(true)} - onMouseLeave={() => setIsVisible(false)} - > -
{children}
- {isVisible && ( -
-
- 📊 {metricName} Comparison -
- -
- {/* Current Week */} -
- {currentWeekLabel} - {format(currentValue)} -
- - {/* Previous Week */} -
- {previousWeekLabel} - {previousValue > 0 ? format(previousValue) : 'N/A'} -
- - {/* Same Week Last Year - ALWAYS SHOWN */} -
- {yoyWeekLabel} - - {hasYoyData ? format(yoyValue) : 'No data'} - -
- - {/* WoW Growth */} -
-
- vs Previous Week - {previousValue > 0 ? ( - = 0 ? 'text-emerald-400' : 'text-red-400'}`}> - {wowGrowth >= 0 ? 'â–²' : 'â–¼'} {Math.abs(wowGrowth).toFixed(1)}% - - ) : ( - N/A - )} -
-
- - {/* YoY Growth - ALWAYS SHOWN */} -
- vs Same Week Last Year - {yoyGrowth !== null ? ( - = 0 ? 'text-emerald-400' : 'text-red-400'}`}> - {yoyGrowth >= 0 ? 'â–²' : 'â–¼'} {Math.abs(yoyGrowth).toFixed(1)}% - - ) : ( - No data - )} -
- - {/* Experiment Baseline Delta (If active) */} - {typeof experimentDelta === 'number' && typeof baselineValue === 'number' && ( -
-
- vs Pre-Experiment Baseline - = 0 ? 'text-emerald-400' : 'text-red-400'}`}> - {experimentDelta >= 0 ? 'â–²' : 'â–¼'} {Math.abs(experimentDelta).toFixed(1)}% - -
-
- Baseline Avg (4w) - {format(baselineValue)} -
-
- )} -
- - {/* Arrow */} -
-
- )} -
- ); -}; - -// Helper to get 4-week baseline prior to the start date -const getBaselineMetrics = (row: WeeklyPivotRow, weeks: string[], expStartDate: string) => { - const startDate = new Date(expStartDate); - const jan1 = new Date(startDate.getFullYear(), 0, 1); - const dayOfYear = Math.floor((startDate.getTime() - jan1.getTime()) / 86400000) + 1; - const weekNum = Math.ceil((dayOfYear + jan1.getDay()) / 7); - const startWeekStr = `${startDate.getFullYear()}-${String(weekNum).padStart(2, '0')}`; - - // Find index of startWeekStr in weeks array - const startIdx = weeks.indexOf(startWeekStr); - - if (startIdx === -1) return null; // Can't compute baseline if start week is not in our data view - - // Weeks before the start date are at startIdx + 1 to startIdx + 4 - let u = 0, r = 0, g = 0, weeksCount = 0; - for (let i = 1; i <= 4; i++) { - const w = weeks[startIdx + i]; - if (w) { - u += (row.unitsByWeek[w] || 0); - r += (row.revenueByWeek[w] || 0); - g += (row.gvByWeek?.[w] || 0); - weeksCount++; - } - } - - if (weeksCount === 0) return null; - return { - units: u / weeksCount, - revenue: r / weeksCount, - gv: g / weeksCount, - cvr: g > 0 ? (u / g) * 100 : 0 - }; -}; - -// Helper to check if a week overlaps with an experiment's date range -const isWeekInExperiment = (weekKey: string, exp: ActiveExperiment): boolean => { - if (!weekKey || !weekKey.includes('-')) return false; - const [yearStr, weekStr] = weekKey.split('-'); - const year = parseInt(yearStr); - const week = parseInt(weekStr); - - // Basic approximation of week start (Jan 1 + (week-1)*7 days) - const weekStart = new Date(year, 0, 1 + (week - 1) * 7); - const weekEnd = new Date(weekStart.getTime() + 6 * 86400000); // add 6 days - - const expStart = new Date(exp.start_date); - const expEnd = exp.end_date ? new Date(exp.end_date) : new Date(); // If no end date, it's ongoing - - // Overlap logic: weekStart <= expEnd && weekEnd >= expStart - return weekStart <= expEnd && weekEnd >= expStart; -}; - -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; - customerFilters: string[]; - vendorStockMap?: Map; - velocityMap?: Map; - buyBoxLostMap?: Map }>; - primaryMetric: 'units' | 'revenue'; - experimentMap?: Map; - onOpenExperiment?: (experimentId: string) => void; -}> = React.memo(({ row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap, primaryMetric, experimentMap, onOpenExperiment }) => { - 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 && ( - - )} - - {experimentMap && ( - - )} -
- {row.line} -
- - {/* Coverage Column (Dedicated) */} - -
- -
- - {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; - const revenue = row.revenueByWeek[week] || 0; - const prevRevenue = row.revenueByWeek[weeks[idx + 1]] || 0; - const gv = row.gvByWeek?.[week] || 0; - const prevGv = row.gvByWeek?.[weeks[idx + 1]] || 0; - - // Parse week as YYYY-WW - const [year, weekNum] = week.split('-'); - const prevWeekNum = weeks[idx + 1]?.split('-')[1] || '-'; - - // Calculate same week last year key (e.g., 2026-05 -> 2025-05) - const lastYearWeek = `${parseInt(year) - 1}-${weekNum}`; - const yoyUnits = row.unitsByWeek[lastYearWeek] || 0; - const yoySpend = row.spendByWeek[lastYearWeek] || 0; - const yoyRevenue = row.revenueByWeek[lastYearWeek] || 0; - const yoyGv = row.gvByWeek?.[lastYearWeek] || 0; - - const cvr = gv > 0 ? (val / gv) * 100 : 0; - const prevCvr = prevGv > 0 ? (prevVal / prevGv) * 100 : 0; - const yoyCvr = yoyGv > 0 ? (yoyUnits / yoyGv) * 100 : 0; - - // Determine if this cell should be highlighted due to an active experiment - const experimentsInWeek = experimentMap?.get(asin)?.filter(exp => isWeekInExperiment(week, exp)) || []; - const hasActiveExperiment = experimentsInWeek.length > 0; - - let highlightClass = ''; - let experimentDelta: number | null = null; - let baselineValue: number | null = null; - - if (hasActiveExperiment) { - const primaryExp = experimentsInWeek[0]; - if (primaryExp.type === 'pricing') highlightClass = 'bg-amber-500/10 border-t border-amber-500/20'; - else if (primaryExp.type === 'advertising') highlightClass = 'bg-rose-500/10 border-t border-rose-500/20'; - else if (primaryExp.type === 'content') highlightClass = 'bg-emerald-500/10 border-t border-emerald-500/20'; - else highlightClass = 'bg-indigo-500/10 border-t border-indigo-500/20'; - - const baseline = getBaselineMetrics(row, weeks, primaryExp.start_date); - if (baseline) { - const myMetric = primaryMetric === 'units' ? val : revenue; - const baseMetric = primaryMetric === 'units' ? baseline.units : baseline.revenue; - if (baseMetric > 0) { - experimentDelta = ((myMetric - baseMetric) / baseMetric) * 100; - baselineValue = baseMetric; - } - } - } - - return ( - - {/* Primary Metric based on toggle */} - {primaryMetric === 'units' ? ( - -
- 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)} -
-
- ) : ( - `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`} - experimentDelta={experimentDelta} - baselineValue={baselineValue} - > -
- 0 ? (sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-400' : 'text-white') : 'text-slate-700'}`}> - {revenue > 0 ? `€${revenue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` : '-'} - - {revenue > 0 && renderGrowth(revenue, prevRevenue)} -
-
- )} - -
- {/* Ads Spend & GV */} -
- {/* Always show Ads Spend (if > 0) */} - {spend > 0 && ( - `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`} - > -
- - Ads: €{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} - - {renderGrowth(spend, prevSpend)} -
-
- )} -
- - {/* GV and CVR with tooltip - Always small/bottom */} - {gv > 0 && ( - -
-
- - GV: {gv.toLocaleString('de-DE')} - - {renderGrowth(gv, prevGv)} -
-
- - CVR: {cvr.toFixed(1)}% - - {renderGrowth(cvr, prevCvr)} -
-
-
- )} -
- - ); - })} - - ); -}); +// Sub-components (MetricDetailTooltip, WeeklyRow) have been extracted to separate files +// to prevent initialization errors and improve code maintainability. const WeeklyGrid: React.FC = ({ data, @@ -1151,7 +749,13 @@ const WeeklyGrid: React.FC = ({ TOTALS {/* COVERAGE Totals Column */} - + +
+ Avg. Coverage + + {aggregateWoc ? `${aggregateWoc.toFixed(1)}w` : '-'} + +
{weeks.map((week, idx) => ( diff --git a/components/WeeklyRow.tsx b/components/WeeklyRow.tsx new file mode 100644 index 0000000..9c65524 --- /dev/null +++ b/components/WeeklyRow.tsx @@ -0,0 +1,292 @@ +import React from 'react'; +import { WeeklyPivotRow } from '../services/dataProcessor'; +import { ActiveExperiment } from '../types'; +import { Top50Badge } from './Top50Badge'; +import { StockBadge } from './StockBadge'; +import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; +import { ExperimentBadge } from './ExperimentBadge'; +import { VendorStockBadge } from './VendorStockBadge'; +import { MetricDetailTooltip } from './MetricDetailTooltip'; + +interface WeeklyRowProps { + row: WeeklyPivotRow; + weeks: string[]; + onDrillDown?: (sku: string) => void; + stockMap?: Map; + top50Ranking?: { eu: Map; uk: Map }; + top50Mode: 'eu' | 'uk'; + sortConfig: { + key: string; + direction: 'asc' | 'desc'; + metric: 'units' | 'spend' | 'revenue' | 'rank' | 'gv'; + } | null; + renderGrowth: (current: number, previous: number) => React.ReactNode; + customerFilters: string[]; + vendorStockMap?: Map; + velocityMap?: Map; + buyBoxLostMap?: Map }>; + primaryMetric: 'units' | 'revenue'; + experimentMap?: Map; + onOpenExperiment?: (experimentId: string) => void; +} + +// Helper to get 4-week baseline prior to the start date +const getBaselineMetrics = (row: WeeklyPivotRow, weeks: string[], expStartDate: string) => { + const startDate = new Date(expStartDate); + const jan1 = new Date(startDate.getFullYear(), 0, 1); + const dayOfYear = Math.floor((startDate.getTime() - jan1.getTime()) / 86400000) + 1; + const weekNum = Math.ceil((dayOfYear + jan1.getDay()) / 7); + const startWeekStr = `${startDate.getFullYear()}-${String(weekNum).padStart(2, '0')}`; + + const startIdx = weeks.indexOf(startWeekStr); + if (startIdx === -1) return null; + + let u = 0, r = 0, g = 0, weeksCount = 0; + for (let i = 1; i <= 4; i++) { + const w = weeks[startIdx + i]; + if (w) { + u += (row.unitsByWeek[w] || 0); + r += (row.revenueByWeek[w] || 0); + g += (row.gvByWeek?.[w] || 0); + weeksCount++; + } + } + + if (weeksCount === 0) return null; + return { + units: u / weeksCount, + revenue: r / weeksCount, + gv: g / weeksCount, + cvr: g > 0 ? (u / g) * 100 : 0 + }; +}; + +// Helper to check if a week overlaps with an experiment's date range +const isWeekInExperiment = (weekKey: string, exp: ActiveExperiment): boolean => { + if (!weekKey || !weekKey.includes('-')) return false; + const [yearStr, weekStr] = weekKey.split('-'); + const year = parseInt(yearStr); + const week = parseInt(weekStr); + + const weekStart = new Date(year, 0, 1 + (week - 1) * 7); + const weekEnd = new Date(weekStart.getTime() + 6 * 86400000); + + const expStart = new Date(exp.start_date); + const expEnd = exp.end_date ? new Date(exp.end_date) : new Date(); + + return weekStart <= expEnd && weekEnd >= expStart; +}; + +export const WeeklyRow: React.FC = React.memo(({ + row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig, + renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap, + primaryMetric, experimentMap, onOpenExperiment +}) => { + 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 && ( + + )} + + {experimentMap && ( + + )} +
+ {row.line} +
+ + {/* Coverage Column */} + +
+ +
+ + {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; + const revenue = row.revenueByWeek[week] || 0; + const prevRevenue = row.revenueByWeek[weeks[idx + 1]] || 0; + const gv = row.gvByWeek?.[week] || 0; + const prevGv = row.gvByWeek?.[weeks[idx + 1]] || 0; + + const [year, weekNum] = week.split('-'); + const prevWeekNum = weeks[idx + 1]?.split('-')[1] || '-'; + const lastYearWeek = `${parseInt(year) - 1}-${weekNum}`; + + const yoyUnits = row.unitsByWeek[lastYearWeek] || 0; + const yoySpend = row.spendByWeek[lastYearWeek] || 0; + const yoyRevenue = row.revenueByWeek[lastYearWeek] || 0; + const yoyGv = row.gvByWeek?.[lastYearWeek] || 0; + + const cvr = gv > 0 ? (val / gv) * 100 : 0; + const prevCvr = prevGv > 0 ? (prevVal / prevGv) * 100 : 0; + + const experimentsInWeek = experimentMap?.get(asin)?.filter(exp => isWeekInExperiment(week, exp)) || []; + const hasActiveExperiment = experimentsInWeek.length > 0; + + let highlightClass = ''; + let experimentDelta: number | null = null; + let baselineValue: number | null = null; + + if (hasActiveExperiment) { + const primaryExp = experimentsInWeek[0]; + if (primaryExp.type === 'pricing') highlightClass = 'bg-amber-500/10 border-t border-amber-500/20'; + else if (primaryExp.type === 'advertising') highlightClass = 'bg-rose-500/10 border-t border-rose-500/20'; + else if (primaryExp.type === 'content') highlightClass = 'bg-emerald-500/10 border-t border-emerald-500/20'; + else highlightClass = 'bg-indigo-500/10 border-t border-indigo-500/20'; + + const baseline = getBaselineMetrics(row, weeks, primaryExp.start_date); + if (baseline) { + const myMetric = primaryMetric === 'units' ? val : revenue; + const baseMetric = primaryMetric === 'units' ? baseline.units : baseline.revenue; + if (baseMetric > 0) { + experimentDelta = ((myMetric - baseMetric) / baseMetric) * 100; + baselineValue = baseMetric; + } + } + } + + return ( + + {primaryMetric === 'units' ? ( + +
+ 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)} +
+
+ ) : ( + `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`} + experimentDelta={experimentDelta} + baselineValue={baselineValue} + > +
+ 0 ? (sortConfig?.key === week && sortConfig.metric === 'revenue' ? 'text-amber-400' : 'text-white') : 'text-slate-700'}`}> + {revenue > 0 ? `€${revenue.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}` : '-'} + + {revenue > 0 && renderGrowth(revenue, prevRevenue)} +
+
+ )} + +
+
+ {spend > 0 && ( + `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`} + > +
+ + Ads: €{spend.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })} + + {renderGrowth(spend, prevSpend)} +
+
+ )} +
+ + {gv > 0 && ( + +
+
+ + GV: {gv.toLocaleString('de-DE')} + + {renderGrowth(gv, prevGv)} +
+
+ + CVR: {cvr.toFixed(1)}% + + {renderGrowth(cvr, prevCvr)} +
+
+
+ )} +
+ + ); + })} + + ); +}); diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 94c5cb6..6060f7d 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -307,11 +307,10 @@ const isAllowedCustomer = (customer: string): boolean => { if (!customer) return false; const normCustomer = customer.trim().toLowerCase(); - // Check if the customer string includes any of our allowed market names + // Use strict equality to prevent loose matches (e.g., 'UK' matching 'Amazon UK') + // which incorrectly included ad spend records in sell-out totals. return ALLOWED_CUSTOMERS.some(allowed => - normCustomer === allowed.toLowerCase() || - normCustomer.includes(allowed.toLowerCase()) || - allowed.toLowerCase().includes(normCustomer) + normCustomer === allowed.toLowerCase() ); }; @@ -412,8 +411,9 @@ export const processCSV = (fileOrContent: File | string): Promise const data: SalesRecord[] = results.data.map((row: any, index: number) => { return mapRowToRecord(row, index); }) - // Filter: Valid Year >= 2023 (include full historical data) AND Allowed Customer - .filter((r: SalesRecord) => r.year >= 2023 && isAllowedCustomer(r.customer)); + // Filter: Valid Year >= 2023 AND Allowed Customer AND non-zero units + // Excluding zero-unit records prevents ad spend or financial adjustments from inflating revenue. + .filter((r: SalesRecord) => r.year >= 2023 && isAllowedCustomer(r.customer) && r.units !== 0); resolve(data); } catch (err) { @@ -440,8 +440,8 @@ export const processExcel = async (file: File): Promise => { const data: SalesRecord[] = jsonData.map((row: any, index: number) => { return mapRowToRecord(row, index); }) - // Filter: Valid Year AND Allowed Customer - .filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer)); + // Filter: Valid Year AND Allowed Customer AND non-zero units + .filter((r: SalesRecord) => r.year > 0 && isAllowedCustomer(r.customer) && r.units !== 0); return data; } catch (error) {