mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:05:24 +02:00
feat: implement experiment tracking in Weekly Sales Grid
This commit is contained in:
+150
-10
@@ -68,7 +68,9 @@ const MetricDetailTooltip: React.FC<{
|
|||||||
metricName: string;
|
metricName: string;
|
||||||
metricColor: string;
|
metricColor: string;
|
||||||
formatValue?: (val: number) => 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 [isVisible, setIsVisible] = useState(false);
|
||||||
|
|
||||||
const wowGrowth = previousValue > 0 ? ((currentValue - previousValue) / previousValue) * 100 : (currentValue > 0 ? 100 : 0);
|
const wowGrowth = previousValue > 0 ? ((currentValue - previousValue) / previousValue) * 100 : (currentValue > 0 ? 100 : 0);
|
||||||
@@ -135,6 +137,22 @@ const MetricDetailTooltip: React.FC<{
|
|||||||
<span className="text-xs font-bold text-slate-600 italic">No data</span>
|
<span className="text-xs font-bold text-slate-600 italic">No data</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Experiment Baseline Delta (If active) */}
|
||||||
|
{typeof experimentDelta === 'number' && typeof baselineValue === 'number' && (
|
||||||
|
<div className="border-t border-fuchsia-500/20 pt-2 mt-2 bg-fuchsia-500/5 -mx-3 px-3 pb-1">
|
||||||
|
<div className="flex justify-between items-center mb-1">
|
||||||
|
<span className="text-[10px] text-fuchsia-400/80 font-bold">vs Pre-Experiment Baseline</span>
|
||||||
|
<span className={`text-xs font-black ${experimentDelta >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||||
|
{experimentDelta >= 0 ? '▲' : '▼'} {Math.abs(experimentDelta).toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-[9px] text-slate-500">Baseline Avg (4w)</span>
|
||||||
|
<span className="text-[10px] font-bold text-slate-400">{format(baselineValue)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Arrow */}
|
{/* 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<{
|
const WeeklyRow: React.FC<{
|
||||||
row: WeeklyPivotRow;
|
row: WeeklyPivotRow;
|
||||||
weeks: string[];
|
weeks: string[];
|
||||||
@@ -236,9 +306,38 @@ const WeeklyRow: React.FC<{
|
|||||||
const yoyRevenue = row.revenueByWeek[lastYearWeek] || 0;
|
const yoyRevenue = row.revenueByWeek[lastYearWeek] || 0;
|
||||||
const yoyGv = row.gvByWeek?.[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 (
|
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]' : ''}`}>
|
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''} ${highlightClass}`}>
|
||||||
{/* Primary Metric based on toggle */}
|
{/* Primary Metric based on toggle */}
|
||||||
{primaryMetric === 'units' ? (
|
{primaryMetric === 'units' ? (
|
||||||
<MetricDetailTooltip
|
<MetricDetailTooltip
|
||||||
@@ -250,6 +349,8 @@ const WeeklyRow: React.FC<{
|
|||||||
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
|
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
|
||||||
metricName="Units"
|
metricName="Units"
|
||||||
metricColor="text-white"
|
metricColor="text-white"
|
||||||
|
experimentDelta={experimentDelta}
|
||||||
|
baselineValue={baselineValue}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<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'}`}>
|
<span className={`text-sm font-bold ${val > 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"
|
metricName="Revenue"
|
||||||
metricColor="text-white"
|
metricColor="text-white"
|
||||||
formatValue={(v) => `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`}
|
formatValue={(v) => `€${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`}
|
||||||
|
experimentDelta={experimentDelta}
|
||||||
|
baselineValue={baselineValue}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className={`text-sm font-bold ${revenue > 0 ? (sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-400' : 'text-white') : 'text-slate-700'}`}>
|
<span className={`text-sm font-bold ${revenue > 0 ? (sortConfig?.key === week && sortConfig.metric === 'spend' ? 'text-amber-400' : 'text-white') : 'text-slate-700'}`}>
|
||||||
@@ -305,7 +408,7 @@ const WeeklyRow: React.FC<{
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* GV with tooltip - Always small/bottom */}
|
{/* GV and CVR with tooltip - Always small/bottom */}
|
||||||
{gv > 0 && (
|
{gv > 0 && (
|
||||||
<MetricDetailTooltip
|
<MetricDetailTooltip
|
||||||
currentValue={gv}
|
currentValue={gv}
|
||||||
@@ -314,14 +417,22 @@ const WeeklyRow: React.FC<{
|
|||||||
currentWeekLabel={`Week ${weekNum} (${year})`}
|
currentWeekLabel={`Week ${weekNum} (${year})`}
|
||||||
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
|
previousWeekLabel={`Week ${prevWeekNum} (${year})`}
|
||||||
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
|
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
|
||||||
metricName="GV (Glance View)"
|
metricName="GV & CVR"
|
||||||
metricColor="text-teal-400"
|
metricColor="text-teal-400"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex flex-col items-center gap-0.5 mt-0.5">
|
||||||
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
|
<div className="flex items-center gap-1">
|
||||||
GV: {gv.toLocaleString('de-DE')}
|
<span className={`text-[9px] font-black tracking-tighter ${sortConfig?.key === week && sortConfig.metric === 'gv' ? 'text-teal-300' : 'text-teal-500/70'}`}>
|
||||||
</span>
|
GV: {gv.toLocaleString('de-DE')}
|
||||||
{renderGrowth(gv, prevGv)}
|
</span>
|
||||||
|
{renderGrowth(gv, prevGv)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<span className="text-[9px] font-bold tracking-tighter text-indigo-400/70">
|
||||||
|
CVR: {cvr.toFixed(1)}%
|
||||||
|
</span>
|
||||||
|
{renderGrowth(cvr, prevCvr)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</MetricDetailTooltip>
|
</MetricDetailTooltip>
|
||||||
)}
|
)}
|
||||||
@@ -364,6 +475,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
|||||||
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
|
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
|
||||||
const [growthThreshold, setGrowthThreshold] = useState(10);
|
const [growthThreshold, setGrowthThreshold] = useState(10);
|
||||||
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
|
||||||
|
const [showActiveExperiments, setShowActiveExperiments] = useState(false);
|
||||||
const [displayCount, setDisplayCount] = useState(50);
|
const [displayCount, setDisplayCount] = useState(50);
|
||||||
const [primaryMetric, setPrimaryMetric] = useState<'units' | 'revenue'>('units');
|
const [primaryMetric, setPrimaryMetric] = useState<'units' | 'revenue'>('units');
|
||||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -466,6 +578,15 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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
|
// Column Filters (Excel-style) - using logic adapted from filterData
|
||||||
if (Object.keys(columnFilters).length > 0) {
|
if (Object.keys(columnFilters).length > 0) {
|
||||||
(Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => {
|
(Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => {
|
||||||
@@ -585,7 +706,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
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
|
// 2. Sort results
|
||||||
const sortedRows = useMemo(() => {
|
const sortedRows = useMemo(() => {
|
||||||
@@ -749,6 +870,25 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Active Experiments Toggle */}
|
||||||
|
{experimentMap && experimentMap.size > 0 && (
|
||||||
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowActiveExperiments(!showActiveExperiments)}
|
||||||
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showActiveExperiments
|
||||||
|
? 'bg-fuchsia-600 text-white shadow-lg shadow-fuchsia-500/20'
|
||||||
|
: 'text-slate-400 hover:text-fuchsia-400'
|
||||||
|
}`}
|
||||||
|
title="Filter grid to show only ASINs with logged experiments"
|
||||||
|
>
|
||||||
|
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
|
||||||
|
</svg>
|
||||||
|
Active Experiments
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Metric Toggle */}
|
{/* Metric Toggle */}
|
||||||
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-auto lg:ml-0">
|
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-auto lg:ml-0">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -104,11 +104,13 @@ export const fetchVendorFilterOptions = async (): Promise<{
|
|||||||
|
|
||||||
const { data: marketData } = await client
|
const { data: marketData } = await client
|
||||||
.from('vendor_daily_data')
|
.from('vendor_daily_data')
|
||||||
.select('market');
|
.select('market')
|
||||||
|
.limit(100000);
|
||||||
|
|
||||||
const { data: tagData } = await client
|
const { data: tagData } = await client
|
||||||
.from('vendor_daily_data')
|
.from('vendor_daily_data')
|
||||||
.select('tags');
|
.select('tags')
|
||||||
|
.limit(100000);
|
||||||
|
|
||||||
const { data: dateMin } = await client
|
const { data: dateMin } = await client
|
||||||
.from('vendor_daily_data')
|
.from('vendor_daily_data')
|
||||||
|
|||||||
Reference in New Issue
Block a user