From 67e93f665351079de2b868b3bd68044aac36d7fb Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Sat, 21 Feb 2026 15:14:22 +0100 Subject: [PATCH] feat: implement experiment tracking in Weekly Sales Grid --- components/WeeklyGrid.tsx | 160 +++++++++++++++++++++++++++++++++++--- services/supabase.ts | 6 +- 2 files changed, 154 insertions(+), 12 deletions(-) diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index a0670c8..a2adf45 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -68,7 +68,9 @@ const MetricDetailTooltip: React.FC<{ metricName: string; metricColor: string; formatValue?: (val: number) => string; -}> = ({ children, currentValue, previousValue, yoyValue, currentWeekLabel, previousWeekLabel, yoyWeekLabel, metricName, metricColor, formatValue }) => { + 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); @@ -135,6 +137,22 @@ const MetricDetailTooltip: React.FC<{ 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 */} @@ -145,6 +163,58 @@ const MetricDetailTooltip: React.FC<{ ); }; +// 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[]; @@ -236,9 +306,38 @@ const WeeklyRow: React.FC<{ 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'}`}> @@ -269,6 +370,8 @@ const WeeklyRow: React.FC<{ metricName="Revenue" metricColor="text-white" formatValue={(v) => `€${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'}`}> @@ -305,7 +408,7 @@ const WeeklyRow: React.FC<{ )}
- {/* GV with tooltip - Always small/bottom */} + {/* GV and CVR with tooltip - Always small/bottom */} {gv > 0 && ( -
- - GV: {gv.toLocaleString('de-DE')} - - {renderGrowth(gv, prevGv)} +
+
+ + GV: {gv.toLocaleString('de-DE')} + + {renderGrowth(gv, prevGv)} +
+
+ + CVR: {cvr.toFixed(1)}% + + {renderGrowth(cvr, prevCvr)} +
)} @@ -364,6 +475,7 @@ const WeeklyGrid: React.FC = ({ const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all'); const [growthThreshold, setGrowthThreshold] = useState(10); const [showOnlyTop50, setShowOnlyTop50] = useState(false); + const [showActiveExperiments, setShowActiveExperiments] = useState(false); const [displayCount, setDisplayCount] = useState(50); const [primaryMetric, setPrimaryMetric] = useState<'units' | 'revenue'>('units'); const scrollContainerRef = useRef(null); @@ -466,6 +578,15 @@ const WeeklyGrid: React.FC = ({ }); } + // Active Experiments Filter + if (showActiveExperiments && experimentMap) { + result = result.filter(r => { + const asin = r.asin.trim().toUpperCase(); + const experiments = experimentMap.get(asin); + return experiments && experiments.length > 0; + }); + } + // Column Filters (Excel-style) - using logic adapted from filterData if (Object.keys(columnFilters).length > 0) { (Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => { @@ -585,7 +706,7 @@ const WeeklyGrid: React.FC = ({ } return result; - }, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]); + }, [rows, debouncedSearch, showOnlyTop50, showActiveExperiments, experimentMap, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]); // 2. Sort results const sortedRows = useMemo(() => { @@ -749,6 +870,25 @@ const WeeklyGrid: React.FC = ({
)} + {/* Active Experiments Toggle */} + {experimentMap && experimentMap.size > 0 && ( +
+ +
+ )} + {/* Metric Toggle */}