From d78a88afe1e190573806d22d1877f6bde83e5b68 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Sat, 21 Feb 2026 15:31:02 +0100 Subject: [PATCH] feat: complete experiments tab analytics optimization with modular kpis and sparklines --- App.tsx | 5 +- components/ExperimentDetail.tsx | 217 +++++++++++++++++++++++++++++--- components/ExperimentsView.tsx | 111 ++++++++++++++-- services/experiments.ts | 68 +++++++--- types.ts | 14 ++- 5 files changed, 362 insertions(+), 53 deletions(-) diff --git a/App.tsx b/App.tsx index 4116df3..fcebcdb 100644 --- a/App.tsx +++ b/App.tsx @@ -58,7 +58,7 @@ const App: React.FC = () => { const [cachedForecastRecords, setCachedForecastRecords] = useState([]); const [lastForecastFile, setLastForecastFile] = useState(null); const [buyBoxLostMap, setBuyBoxLostMap] = useState }>>(new Map()); - + // Experiments state const [experimentMap, setExperimentMap] = useState>(new Map()); const [selectedExperimentId, setSelectedExperimentId] = useState(null); @@ -66,7 +66,7 @@ const App: React.FC = () => { const [preselectedAsins, setPreselectedAsins] = useState([]); const [preselectedMarketplace, setPreselectedMarketplace] = useState(''); const [preselectedLine, setPreselectedLine] = useState(''); - + // Available product lines (for experiment form) const availableProductLines = useMemo(() => { const lines = new Set(rawData.map(r => r.line).filter(Boolean)); @@ -961,6 +961,7 @@ const App: React.FC = () => { setPreselectedMarketplace(filters.customer[0] || ''); setShowCreateExperiment(true); }} + salesData={rawData} /> diff --git a/components/ExperimentDetail.tsx b/components/ExperimentDetail.tsx index cf423d4..7cc2c9e 100644 --- a/components/ExperimentDetail.tsx +++ b/components/ExperimentDetail.tsx @@ -1,7 +1,8 @@ -import React, { useState, useEffect, useCallback } from 'react'; -import { Experiment, ExperimentCreateInput, ExperimentType, ExperimentStatus, ExperimentMetric } from '../types'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; +import { Experiment, ExperimentCreateInput, ExperimentType, ExperimentStatus, ExperimentMetric, CombinedKPIs } from '../types'; import { getExperiment, updateExperiment, deleteExperiment, calculateExperimentPerformance } from '../services/experiments'; import { ExperimentStatusBadge, ExperimentTypeBadge } from './ExperimentBadge'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, ReferenceLine } from 'recharts'; interface ExperimentDetailProps { experimentId: string | null; @@ -63,6 +64,64 @@ const ExperimentDetail: React.FC = ({ experimentId, onClo } }; + const chartData = useMemo(() => { + if (!experiment || !salesData.length) return []; + + const asinSet = new Set(experiment.asins.map(a => a.toUpperCase())); + const weeklyMap = new Map(); + + salesData.forEach(r => { + const asin = (r.asin || '').toUpperCase(); + if (!asinSet.has(asin)) return; + + const weekNum = r.week || 1; + const yearStr = r.year || new Date().getFullYear(); + const weekStr = `${yearStr}-W${String(weekNum).padStart(2, '0')}`; + + if (!weeklyMap.has(weekStr)) { + const d = new Date(yearStr, 0, 1 + (weekNum - 1) * 7); + weeklyMap.set(weekStr, { + name: weekStr, + timestamp: d.getTime(), + units: 0, + revenue: 0, + gv: 0, + spend: 0, + }); + } + + const w = weeklyMap.get(weekStr); + w.units += r.unitsTotal || 0; + w.revenue += r.salesTotal || 0; + w.gv += r.glanceViews || 0; + w.spend += r.cost || 0; + }); + + const arr = Array.from(weeklyMap.values()).map(w => ({ + ...w, + cvr: w.gv > 0 ? (w.units / w.gv) * 100 : 0, + acos: w.revenue > 0 ? (w.spend / w.revenue) * 100 : 0 + })).sort((a, b) => a.timestamp - b.timestamp); + + return arr.slice(-16); // Let's keep the last 16 weeks to ensure enough window before and during + }, [experiment, salesData]); + + const getChartMetricColor = (metric: string) => { + switch (metric) { + case 'cvr': return '#34d399'; // emerald + case 'gv': return '#a78bfa'; // purple + case 'acos': return '#fb7185'; // rose + case 'revenue': return '#fbbf24'; // amber + default: return '#818cf8'; // indigo for units + } + }; + + const formatChartMetric = (val: number, metric: string) => { + if (metric === 'cvr' || metric === 'acos' || metric === 'ctr') return `${val.toFixed(1)}%`; + if (metric === 'revenue' || metric === 'cost') return `€${Math.round(val).toLocaleString('de-DE')}`; + return Math.round(val).toLocaleString('de-DE'); + }; + if (!experimentId) return null; return ( @@ -135,30 +194,45 @@ const ExperimentDetail: React.FC = ({ experimentId, onClo ) : experiment ? (
{/* Performance Cards */} - {(experiment.experiment_units !== undefined || experiment.baseline_units !== undefined) && ( -
+ {(experiment.experiment_units !== undefined) && ( +
+ {experiment.type === 'pricing' && ( + <> + + + + )} + {experiment.type === 'advertising' && ( + <> + + + + )} + {experiment.type === 'content' && ( + <> + + + + )} + {experiment.type === 'promotion' && ( + <> + + + + )} + - - = 0 ? '+' : ''}${experiment.actual_lift_percent}%` : '—'} - color={experiment.actual_lift_percent && experiment.actual_lift_percent >= 0 ? 'emerald' : 'red'} + color={experiment.actual_lift_percent !== undefined && experiment.actual_lift_percent >= 0 ? 'emerald' : 'red'} />
@@ -174,6 +248,76 @@ const ExperimentDetail: React.FC = ({ experimentId, onClo )} + {/* Dynamic Metric Chart */} + {chartData.length > 0 && ( +
+
+

+ Timeline Impact ({experiment.primary_metric.toUpperCase()}) +

+
+
+ + + + val.split('-')[1]} // Just show W12 + /> + formatChartMetric(val, experiment.primary_metric)} + width={60} + /> + { + if (experiment.primary_metric === 'cvr') return [`${value.toFixed(1)}%`, 'CVR']; + if (experiment.primary_metric === 'acos') return [`${value.toFixed(1)}%`, 'ACOS']; + if (experiment.primary_metric === 'revenue') return [`€${value.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`, 'Rev']; + return [value.toLocaleString('de-DE'), 'Units']; + }} + /> + {/* Reference Line for Start Date */} + { + const dDate = new Date(experiment.start_date); + const jan1 = new Date(dDate.getFullYear(), 0, 1); + const dayOfYear = Math.floor((dDate.getTime() - jan1.getTime()) / 86400000) + 1; + const wN = Math.ceil((dayOfYear + jan1.getDay()) / 7); + return `${dDate.getFullYear()}-W${String(wN).padStart(2, '0')}`; + })()} + stroke="#e2e8f0" + strokeDasharray="3 3" + label={{ position: 'top', value: 'Started', fill: '#e2e8f0', fontSize: 10 }} + /> + + + +
+
+ )} + {/* Details Grid */}
{/* Left Column */} @@ -399,6 +543,43 @@ const PerformanceCard: React.FC<{ label: string; value: string; color: string }> ); }; +// Metric Delta Card Component +const MetricDeltaCard: React.FC<{ + title: string; + baseline: number | undefined; + experimentVal: number | undefined; + format?: 'number' | 'currency' | 'percent'; + invertColors?: boolean; +}> = ({ title, baseline, experimentVal, format = 'number', invertColors = false }) => { + if (baseline === undefined || experimentVal === undefined) return null; + const delta = baseline > 0 ? ((experimentVal - baseline) / baseline) * 100 : 0; + + const isPositive = invertColors ? delta <= 0 : delta >= 0; + const colorClass = isPositive ? 'text-emerald-400' : 'text-red-400'; + const bgClass = isPositive ? 'bg-emerald-500/10 border-emerald-500/30' : 'bg-red-500/10 border-red-500/30'; + + const formatter = (val: number) => { + if (format === 'percent') return `${val}%`; + if (format === 'currency') return `€${val.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; + return val.toLocaleString('de-DE'); + }; + + return ( +
+
{title}
+
+
+
{formatter(experimentVal)}
+
+ {delta > 0 ? '+' : ''}{delta.toFixed(1)}% +
+
+
vs {formatter(baseline)} baseline avg
+
+
+ ); +}; + // Detail Section Component const DetailSection: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
diff --git a/components/ExperimentsView.tsx b/components/ExperimentsView.tsx index 553dc5c..cdea87f 100644 --- a/components/ExperimentsView.tsx +++ b/components/ExperimentsView.tsx @@ -1,15 +1,92 @@ import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { ExperimentListItem, ExperimentType, ExperimentStatus } from '../types'; +import { ExperimentListItem, ExperimentType, ExperimentStatus, CombinedKPIs } from '../types'; import { listExperiments, deleteExperiment, getExperimentStatusColor, getExperimentTypeColor, getExperimentIcon } from '../services/experiments'; import MultiSelectDropdown from './MultiSelectDropdown'; import { ExperimentBadge, ExperimentStatusBadge, ExperimentTypeBadge } from './ExperimentBadge'; +import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts'; + +const getChartMetricColor = (metric: string) => { + switch (metric) { + case 'cvr': return '#34d399'; // emerald + case 'gv': return '#a78bfa'; // purple + case 'acos': return '#fb7185'; // rose + case 'revenue': return '#fbbf24'; // amber + default: return '#818cf8'; // indigo for units + } +}; + +const ExperimentSparkline: React.FC<{ experiment: ExperimentListItem; salesData: CombinedKPIs[] }> = ({ experiment, salesData }) => { + const chartData = useMemo(() => { + if (!experiment.asins || !salesData.length) return []; + + const asinSet = new Set(experiment.asins.map(a => a.toUpperCase())); + const weeklyMap = new Map(); + + salesData.forEach(r => { + const asin = (r.asin || '').toUpperCase(); + if (!asinSet.has(asin)) return; + + const weekNum = r.week || 1; + const yearStr = r.year || new Date().getFullYear(); + const weekStr = `${yearStr}-W${String(weekNum).padStart(2, '0')}`; + + if (!weeklyMap.has(weekStr)) { + const d = new Date(yearStr, 0, 1 + (weekNum - 1) * 7); + weeklyMap.set(weekStr, { + name: weekStr, + timestamp: d.getTime(), + units: 0, + revenue: 0, + gv: 0, + spend: 0, + }); + } + + const w = weeklyMap.get(weekStr); + w.units += r.unitsTotal || 0; + w.revenue += r.salesTotal || 0; + w.gv += r.glanceViews || 0; + w.spend += r.cost || 0; + }); + + const arr = Array.from(weeklyMap.values()).map(w => ({ + ...w, + cvr: w.gv > 0 ? (w.units / w.gv) * 100 : 0, + acos: w.revenue > 0 ? (w.spend / w.revenue) * 100 : 0 + })).sort((a, b) => a.timestamp - b.timestamp); + + return arr.slice(-12); + }, [experiment, salesData]); + + if (chartData.length < 2) return
; + + const metric = experiment.primary_metric || 'units'; + + return ( +
+ + + + + + +
+ ); +}; interface ExperimentsViewProps { onOpenDetail: (experimentId: string) => void; onOpenCreate: () => void; + salesData?: CombinedKPIs[]; } -const ExperimentsView: React.FC = ({ onOpenDetail, onOpenCreate }) => { +const ExperimentsView: React.FC = ({ onOpenDetail, onOpenCreate, salesData = [] }) => { const [experiments, setExperiments] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); @@ -57,7 +134,7 @@ const ExperimentsView: React.FC = ({ onOpenDetail, onOpenC const handleDelete = async (id: string, e: React.MouseEvent) => { e.stopPropagation(); if (!confirm('Are you sure you want to delete this experiment?')) return; - + try { await deleteExperiment(id); loadExperiments(); @@ -78,7 +155,7 @@ const ExperimentsView: React.FC = ({ onOpenDetail, onOpenC active: experiments.filter(e => e.status === 'active').length, planned: experiments.filter(e => e.status === 'planned').length, avgLift: experiments.filter(e => e.actual_lift_percent !== undefined) - .reduce((sum, e) => sum + (e.actual_lift_percent || 0), 0) / + .reduce((sum, e) => sum + (e.actual_lift_percent || 0), 0) / Math.max(1, experiments.filter(e => e.actual_lift_percent !== undefined).length), }), [experiments]); @@ -192,7 +269,7 @@ const ExperimentsView: React.FC = ({ onOpenDetail, onOpenC ASINs Duration Progress - Lift + Impact Owner Actions @@ -241,13 +318,21 @@ const ExperimentsView: React.FC = ({ onOpenDetail, onOpenC
- {exp.actual_lift_percent !== undefined ? ( - = 0 ? 'text-emerald-400' : 'text-red-400'}`}> - {exp.actual_lift_percent >= 0 ? '+' : ''}{exp.actual_lift_percent}% - - ) : ( - - )} +
+ {salesData.length > 0 && } +
+ {exp.actual_lift_percent !== undefined ? ( + = 0 ? 'text-emerald-400' : 'text-red-400'}`}> + {exp.actual_lift_percent >= 0 ? '+' : ''}{exp.actual_lift_percent}% + + ) : ( + + )} +
+ {exp.primary_metric || 'units'} +
+
+
{exp.owner || '—'} @@ -303,4 +388,6 @@ const StatCard: React.FC<{ label: string; value: string | number; color: string ); }; + + export default ExperimentsView; diff --git a/services/experiments.ts b/services/experiments.ts index 47a20cd..6daf827 100644 --- a/services/experiments.ts +++ b/services/experiments.ts @@ -6,7 +6,7 @@ import { ExperimentType, ExperimentStatus, } from '../types'; -import { SalesRecord } from '../types'; +import { CombinedKPIs } from '../types'; const API_BASE = '/api/experiments'; @@ -109,7 +109,7 @@ export const listExperiments = async ( const today = new Date(); const startDate = new Date(exp.start_date); const endDate = exp.end_date ? new Date(exp.end_date) : null; - + let progressPercent = 0; if (endDate) { const totalDays = endDate.getTime() - startDate.getTime(); @@ -123,10 +123,12 @@ export const listExperiments = async ( type: exp.type, status: exp.status, asin_count: exp.asins?.length || 0, + asins: exp.asins || [], marketplace: exp.marketplace, start_date: exp.start_date, end_date: exp.end_date, progress_percent: Math.round(progressPercent), + primary_metric: exp.primary_metric, actual_lift_percent: exp.actual_lift_percent, owner: exp.owner, }; @@ -138,7 +140,7 @@ export const getActiveExperimentsForASINs = async ( ): Promise> => { const params = new URLSearchParams(); asins.forEach(asin => params.append('asin', asin)); - + const response = await fetch(`${API_BASE}?${params.toString()}&status=active`); if (!response.ok) { @@ -148,10 +150,10 @@ export const getActiveExperimentsForASINs = async ( const data = await response.json(); const today = new Date().toISOString().split('T')[0]; - + // Filter experiments that are currently active - const activeExperiments = data.filter((exp: any) => - exp.status === 'active' && + const activeExperiments = data.filter((exp: any) => + exp.status === 'active' && exp.start_date <= today && (!exp.end_date || exp.end_date >= today) ); @@ -191,17 +193,23 @@ export const getActiveExperimentsForASINs = async ( export const calculateExperimentPerformance = async ( experiment: Experiment, - salesData: SalesRecord[] + salesData: CombinedKPIs[] ): Promise<{ baseline_units: number; baseline_revenue: number; + baseline_gv: number; + baseline_cvr: number; + baseline_acos: number; experiment_units: number; experiment_revenue: number; + experiment_gv: number; + experiment_cvr: number; + experiment_acos: number; actual_lift_percent: number; }> => { const startDate = new Date(experiment.start_date); const endDate = experiment.end_date ? new Date(experiment.end_date) : new Date(); - + // Calculate baseline period (same duration before experiment) const durationMs = endDate.getTime() - startDate.getTime(); const baselineStart = new Date(startDate.getTime() - durationMs); @@ -209,40 +217,62 @@ export const calculateExperimentPerformance = async ( // Filter sales data for experiment ASINs const asinSet = new Set(experiment.asins.map(a => a.toUpperCase())); - + const baselineData = salesData.filter(r => { - const recordDate = new Date(`${r.year}-${getMonthNumber(r.month)}-01`); + const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7); return asinSet.has(r.asin.toUpperCase()) && recordDate >= baselineStart && recordDate < baselineEnd; }); const experimentData = salesData.filter(r => { - const recordDate = new Date(`${r.year}-${getMonthNumber(r.month)}-01`); + const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7); return asinSet.has(r.asin.toUpperCase()) && recordDate >= startDate && recordDate <= endDate; }); - const baseline_units = baselineData.reduce((sum, r) => sum + r.units, 0); - const baseline_revenue = baselineData.reduce((sum, r) => sum + r.sellOut, 0); - const experiment_units = experimentData.reduce((sum, r) => sum + r.units, 0); - const experiment_revenue = experimentData.reduce((sum, r) => sum + r.sellOut, 0); + const baseline_units = baselineData.reduce((sum, r) => sum + r.unitsTotal, 0); + const baseline_revenue = baselineData.reduce((sum, r) => sum + r.salesTotal, 0); + const baseline_gv = baselineData.reduce((sum, r) => sum + (r.glanceViews || 0), 0); + const baseline_cvr = baseline_gv > 0 ? (baseline_units / baseline_gv) * 100 : 0; + const baseline_spend = baselineData.reduce((sum, r) => sum + (r.cost || 0), 0); + const baseline_acos = baseline_revenue > 0 ? (baseline_spend / baseline_revenue) * 100 : 0; - const actual_lift_percent = baseline_units > 0 - ? ((experiment_units - baseline_units) / baseline_units) * 100 - : 0; + const experiment_units = experimentData.reduce((sum, r) => sum + r.unitsTotal, 0); + const experiment_revenue = experimentData.reduce((sum, r) => sum + r.salesTotal, 0); + const experiment_gv = experimentData.reduce((sum, r) => sum + (r.glanceViews || 0), 0); + const experiment_cvr = experiment_gv > 0 ? (experiment_units / experiment_gv) * 100 : 0; + const experiment_spend = experimentData.reduce((sum, r) => sum + (r.cost || 0), 0); + const experiment_acos = experiment_revenue > 0 ? (experiment_spend / experiment_revenue) * 100 : 0; + + let actual_lift_percent = 0; + if (experiment.primary_metric === 'cvr' && baseline_cvr > 0) { + actual_lift_percent = ((experiment_cvr - baseline_cvr) / baseline_cvr) * 100; + } else if (experiment.primary_metric === 'revenue' && baseline_revenue > 0) { + actual_lift_percent = ((experiment_revenue - baseline_revenue) / baseline_revenue) * 100; + } else if (experiment.primary_metric === 'acos' && baseline_acos > 0) { + actual_lift_percent = ((baseline_acos - experiment_acos) / Math.max(0.1, baseline_acos)) * 100; // inverted, lower is better + } else if (baseline_units > 0) { + actual_lift_percent = ((experiment_units - baseline_units) / baseline_units) * 100; + } return { baseline_units, baseline_revenue, + baseline_gv, + baseline_cvr: Math.round(baseline_cvr * 10) / 10, + baseline_acos: Math.round(baseline_acos * 10) / 10, experiment_units, experiment_revenue, + experiment_gv, + experiment_cvr: Math.round(experiment_cvr * 10) / 10, + experiment_acos: Math.round(experiment_acos * 10) / 10, actual_lift_percent: Math.round(actual_lift_percent * 10) / 10, }; }; export const updateExperimentResults = async ( experimentId: string, - salesData: SalesRecord[] + salesData: CombinedKPIs[] ): Promise => { const experiment = await getExperiment(experimentId); if (!experiment) throw new Error('Experiment not found'); diff --git a/types.ts b/types.ts index 05aafcf..45d1426 100644 --- a/types.ts +++ b/types.ts @@ -300,15 +300,23 @@ export interface Experiment { hypothesis?: string; primary_metric: ExperimentMetric; target_lift_percent?: number; - + // Results baseline_units?: number; baseline_revenue?: number; + baseline_gv?: number; + baseline_cvr?: number; + baseline_acos?: number; + experiment_units?: number; experiment_revenue?: number; + experiment_gv?: number; + experiment_cvr?: number; + experiment_acos?: number; + actual_lift_percent?: number; statistical_significance?: number; - + learnings?: string; owner?: string; } @@ -333,10 +341,12 @@ export interface ExperimentListItem { type: ExperimentType; status: ExperimentStatus; asin_count: number; + asins: string[]; marketplace: string; start_date: string; end_date?: string; progress_percent: number; + primary_metric: ExperimentMetric; actual_lift_percent?: number; owner?: string; }