feat: rebuild Experiments tab with Difference-in-Differences analysis and Bayesian verdicts

Replace the basic CRUD experiment tracker with a scientifically rigorous A/B testing system:

- Add DiD analysis engine (services/experimentAnalysis.ts) that computes treatment vs control
  group comparisons across 7 metrics (units, sessions, CVR, CTR, ROAS, revenue, ACOS)
- Implement Bayesian verdict system (Winner/Loser/Inconclusive) using posterior probability
  with normal CDF approximation (Abramowitz & Stegun erf, no external deps)
- Build counterfactual time series for trend charts (actual vs estimated without change)
- Rewrite ExperimentsView as single component with 3 inline sub-views (list, detail, create)
  replacing the previous modal-based ExperimentDetail and ExperimentForm
- Add control group support, change annotations (before→after diffs), and SEO experiment type
- New types: ExperimentChangeAnnotation, DiDMetricResult, DifferenceInDifferencesResult,
  ExperimentVerdict
- Simplify App.tsx by removing experiment modal state (5 useState hooks eliminated)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-02-23 15:42:40 +01:00
co-authored by Claude Opus 4.6
parent 7ee33671cc
commit 24df5935cc
6 changed files with 1495 additions and 433 deletions
+9 -54
View File
@@ -23,8 +23,7 @@ const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
const ForecastView = lazy(() => import('./components/ForecastView'));
const VendorDataView = lazy(() => import('./components/VendorDataView'));
const ExperimentsView = lazy(() => import('./components/ExperimentsView'));
const ExperimentDetail = lazy(() => import('./components/ExperimentDetail'));
const ExperimentForm = lazy(() => import('./components/ExperimentForm'));
// ExperimentDetail and ExperimentForm are now integrated into ExperimentsView
// Loading fallback component
const LoadingSpinner = () => (
@@ -64,17 +63,7 @@ const App: React.FC = () => {
// Experiments state
const [experimentMap, setExperimentMap] = useState<Map<string, ActiveExperiment[]>>(new Map());
const [selectedExperimentId, setSelectedExperimentId] = useState<string | null>(null);
const [showCreateExperiment, setShowCreateExperiment] = useState(false);
const [preselectedAsins, setPreselectedAsins] = useState<string[]>([]);
const [preselectedMarketplace, setPreselectedMarketplace] = useState<string>('');
const [preselectedLine, setPreselectedLine] = useState<string>('');
// Available product lines (for experiment form)
const availableProductLines = useMemo(() => {
const lines = new Set(rawData.map(r => r.line).filter(Boolean));
return Array.from(lines).sort();
}, [rawData]);
// Experiment modal state removed — detail/create are now inline in ExperimentsView
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
@@ -349,13 +338,10 @@ const App: React.FC = () => {
setView('ads');
}, []);
// Create experiment for a specific line
const handleCreateExperimentForLine = useCallback((line: string) => {
if (!line) return;
setPreselectedLine(line);
setPreselectedMarketplace(filters.customer[0] || '');
setShowCreateExperiment(true);
}, [filters.customer]);
// Create experiment for a specific line — now handled inline by ExperimentsView
const handleCreateExperimentForLine = useCallback((_line: string) => {
setView('experiments');
}, []);
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
useEffect(() => {
@@ -902,7 +888,7 @@ const App: React.FC = () => {
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
experimentMap={experimentMap}
onOpenExperiment={(id) => setSelectedExperimentId(id)}
onOpenExperiment={() => setView('experiments')}
/>
</div>
</Suspense>
@@ -962,13 +948,8 @@ const App: React.FC = () => {
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'experiments' ? '' : 'hidden'}>
<ExperimentsView
onOpenDetail={(id) => setSelectedExperimentId(id)}
onOpenCreate={() => {
setPreselectedAsins([]);
setPreselectedMarketplace(filters.customer[0] || '');
setShowCreateExperiment(true);
}}
salesData={unfilteredCombinedData}
onExperimentsFetch={handleExperimentsFetch}
/>
</div>
</Suspense>
@@ -1036,33 +1017,7 @@ const App: React.FC = () => {
)
}
{/* Experiment Detail Modal */}
{selectedExperimentId && (
<ExperimentDetail
experimentId={selectedExperimentId}
onClose={() => setSelectedExperimentId(null)}
salesData={unfilteredCombinedData}
/>
)}
{/* Create Experiment Modal */}
{showCreateExperiment && (
<ExperimentForm
onClose={() => {
setShowCreateExperiment(false);
setPreselectedAsins([]);
setPreselectedMarketplace('');
setPreselectedLine('');
}}
onSuccess={() => {
handleExperimentsFetch();
}}
initialAsins={preselectedAsins}
initialMarketplace={preselectedMarketplace}
initialLine={preselectedLine}
availableLines={availableProductLines}
/>
)}
{/* Experiment modals removed — detail/create are now inline in ExperimentsView */}
</div >
);
+2 -1
View File
@@ -98,11 +98,12 @@ export const ExperimentTypeBadge: React.FC<ExperimentTypeBadgeProps> = ({ type,
lg: 'px-3 py-1.5 text-sm',
};
const typeLabels = {
const typeLabels: Record<string, string> = {
pricing: 'Pricing',
advertising: 'Advertising',
content: 'Content',
promotion: 'Promotion',
seo: 'SEO',
};
return (
File diff suppressed because it is too large Load Diff
+342
View File
@@ -0,0 +1,342 @@
import {
Experiment,
CombinedKPIs,
DifferenceInDifferencesResult,
DiDMetricResult,
ExperimentVerdict,
} from '../types';
import { getExperimentAsins } from './experiments';
// ============ Math Helpers ============
function erf(x: number): number {
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
const sign = x < 0 ? -1 : 1;
const abs = Math.abs(x);
const t = 1.0 / (1.0 + p * abs);
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-abs * abs);
return sign * y;
}
function normalCDF(x: number): number {
return 0.5 * (1 + erf(x / Math.sqrt(2)));
}
// ============ Weekly Metric Aggregation ============
interface WeeklyMetrics {
week: string; // "2025-W05"
timestamp: number;
units: number;
revenue: number;
sessions: number; // glanceViews
cost: number;
clicks: number;
impressions: number;
}
interface ComputedWeeklyMetrics extends WeeklyMetrics {
cvr: number;
ctr: number;
roas: number;
}
function aggregateWeeklyMetrics(
asinSet: Set<string>,
salesData: CombinedKPIs[],
marketplace: string
): ComputedWeeklyMetrics[] {
const weeklyMap = new Map<string, WeeklyMetrics>();
for (const r of salesData) {
const asin = (r.asin || '').toUpperCase();
if (!asinSet.has(asin)) continue;
const mkt = (r.marketplace || (r as any).customer || '').toLowerCase();
if (marketplace && marketplace !== 'All' && !mkt.includes(marketplace.toLowerCase())) continue;
const weekNum = r.week || 1;
const year = r.year || new Date().getFullYear();
const key = `${year}-W${String(weekNum).padStart(2, '0')}`;
if (!weeklyMap.has(key)) {
const d = new Date(year, 0, 1 + (weekNum - 1) * 7);
weeklyMap.set(key, {
week: key,
timestamp: d.getTime(),
units: 0,
revenue: 0,
sessions: 0,
cost: 0,
clicks: 0,
impressions: 0,
});
}
const w = weeklyMap.get(key)!;
w.units += r.unitsTotal ?? (r as any).units ?? 0;
w.revenue += r.salesTotal ?? (r as any).sellOut ?? 0;
w.sessions += r.glanceViews || 0;
w.cost += r.cost || 0;
w.clicks += r.clicks || 0;
w.impressions += r.impressions || 0;
}
return Array.from(weeklyMap.values())
.sort((a, b) => a.timestamp - b.timestamp)
.map(w => ({
...w,
cvr: w.sessions > 0 ? (w.units / w.sessions) * 100 : 0,
ctr: w.impressions > 0 ? (w.clicks / w.impressions) * 100 : 0,
roas: w.cost > 0 ? w.revenue / w.cost : 0,
}));
}
function getMetricValue(w: ComputedWeeklyMetrics, metric: string): number {
switch (metric) {
case 'units': return w.units;
case 'sessions': return w.sessions;
case 'cvr': return w.cvr;
case 'ctr': return w.ctr;
case 'roas': return w.roas;
case 'revenue': return w.revenue;
case 'acos': return w.cost > 0 && w.revenue > 0 ? (w.cost / w.revenue) * 100 : 0;
default: return w.units;
}
}
// ============ DiD Computation ============
function parseLocalDate(dateStr?: string): Date {
if (!dateStr) return new Date();
const [y, m, d] = dateStr.split('T')[0].split('-');
return new Date(Number(y), Number(m) - 1, Number(d));
}
function splitPeriods(
data: ComputedWeeklyMetrics[],
startTs: number,
endTs: number,
beforeStartTs: number
): { before: ComputedWeeklyMetrics[]; after: ComputedWeeklyMetrics[] } {
const before: ComputedWeeklyMetrics[] = [];
const after: ComputedWeeklyMetrics[] = [];
for (const w of data) {
if (w.timestamp >= beforeStartTs && w.timestamp < startTs) {
before.push(w);
} else if (w.timestamp >= startTs && w.timestamp <= endTs) {
after.push(w);
}
}
return { before, after };
}
function avgMetric(data: ComputedWeeklyMetrics[], metric: string): number {
if (data.length === 0) return 0;
const sum = data.reduce((s, w) => s + getMetricValue(w, metric), 0);
return sum / data.length;
}
const METRICS = ['units', 'sessions', 'cvr', 'ctr', 'roas', 'revenue', 'acos'];
const LOWER_IS_BETTER = new Set(['acos']);
function computeMetricDiD(
treatmentBefore: ComputedWeeklyMetrics[],
treatmentAfter: ComputedWeeklyMetrics[],
controlBefore: ComputedWeeklyMetrics[],
controlAfter: ComputedWeeklyMetrics[],
metric: string,
hasControlGroup: boolean
): DiDMetricResult {
const tBefore = avgMetric(treatmentBefore, metric);
const tAfter = avgMetric(treatmentAfter, metric);
const cBefore = hasControlGroup ? avgMetric(controlBefore, metric) : 0;
const cAfter = hasControlGroup ? avgMetric(controlAfter, metric) : 0;
let didEstimate: number;
if (hasControlGroup) {
didEstimate = (tAfter - tBefore) - (cAfter - cBefore);
} else {
didEstimate = tAfter - tBefore;
}
// For ACOS, lower is better — invert the estimate
if (LOWER_IS_BETTER.has(metric)) {
didEstimate = -didEstimate;
}
const liftPercent = tBefore !== 0 ? (didEstimate / Math.abs(tBefore)) * 100 : 0;
// Bayesian posterior probability
const weeklyDiffs: number[] = [];
const minLen = Math.min(treatmentAfter.length, hasControlGroup ? controlAfter.length : treatmentAfter.length);
for (let i = 0; i < minLen; i++) {
const tVal = getMetricValue(treatmentAfter[i], metric);
let diff: number;
if (hasControlGroup && controlAfter[i]) {
const cVal = getMetricValue(controlAfter[i], metric);
diff = (tVal - tBefore) - (cVal - cBefore);
} else {
diff = tVal - tBefore;
}
if (LOWER_IS_BETTER.has(metric)) diff = -diff;
weeklyDiffs.push(diff);
}
let posteriorProb = 0.5;
if (weeklyDiffs.length >= 3) {
const mean = weeklyDiffs.reduce((s, v) => s + v, 0) / weeklyDiffs.length;
const variance = weeklyDiffs.reduce((s, v) => s + (v - mean) ** 2, 0) / (weeklyDiffs.length - 1);
const se = Math.sqrt(variance / weeklyDiffs.length);
if (se > 0) {
posteriorProb = normalCDF(mean / se);
} else {
posteriorProb = mean > 0 ? 1 : mean < 0 ? 0 : 0.5;
}
}
return {
treatment_before: Math.round(tBefore * 100) / 100,
treatment_after: Math.round(tAfter * 100) / 100,
control_before: Math.round(cBefore * 100) / 100,
control_after: Math.round(cAfter * 100) / 100,
did_estimate: Math.round(didEstimate * 100) / 100,
lift_percent: Math.round(liftPercent * 10) / 10,
posterior_prob_positive: Math.round(posteriorProb * 1000) / 1000,
};
}
export function computeDiD(
experiment: Experiment,
salesData: CombinedKPIs[]
): DifferenceInDifferencesResult {
const startDate = parseLocalDate(experiment.start_date);
const endDate = experiment.end_date ? parseLocalDate(experiment.end_date) : new Date();
const durationMs = endDate.getTime() - startDate.getTime();
const beforeStart = new Date(startDate.getTime() - durationMs);
const treatmentAsinSet = getExperimentAsins(experiment.asins || [], salesData);
const treatmentWeekly = aggregateWeeklyMetrics(treatmentAsinSet, salesData, experiment.marketplace);
const hasControlGroup = (experiment.control_asins || []).length > 0;
let controlWeekly: ComputedWeeklyMetrics[] = [];
if (hasControlGroup) {
const controlAsinSet = getExperimentAsins(experiment.control_asins, salesData);
controlWeekly = aggregateWeeklyMetrics(controlAsinSet, salesData, experiment.marketplace);
}
const startTs = startDate.getTime();
const endTs = endDate.getTime();
const beforeStartTs = beforeStart.getTime();
const tSplit = splitPeriods(treatmentWeekly, startTs, endTs, beforeStartTs);
const cSplit = hasControlGroup
? splitPeriods(controlWeekly, startTs, endTs, beforeStartTs)
: { before: [] as ComputedWeeklyMetrics[], after: [] as ComputedWeeklyMetrics[] };
const metrics: Record<string, DiDMetricResult> = {};
for (const metric of METRICS) {
metrics[metric] = computeMetricDiD(
tSplit.before, tSplit.after,
cSplit.before, cSplit.after,
metric, hasControlGroup
);
}
return {
metrics,
computed_at: new Date().toISOString(),
};
}
// ============ Verdict ============
export function computeVerdict(
didResult: DifferenceInDifferencesResult,
primaryMetric: string
): { verdict: ExperimentVerdict; probability: number } {
const result = didResult.metrics[primaryMetric];
if (!result) return { verdict: 'inconclusive', probability: 0.5 };
const prob = result.posterior_prob_positive;
if (prob >= 0.90) return { verdict: 'winner', probability: prob };
if (prob <= 0.10) return { verdict: 'loser', probability: prob };
return { verdict: 'inconclusive', probability: prob };
}
// ============ Counterfactual Time Series ============
export interface TrendDataPoint {
week: string;
timestamp: number;
actual: number;
counterfactual: number;
}
export function buildCounterfactualSeries(
experiment: Experiment,
salesData: CombinedKPIs[],
metric: string
): TrendDataPoint[] {
const startDate = parseLocalDate(experiment.start_date);
const endDate = experiment.end_date ? parseLocalDate(experiment.end_date) : new Date();
const durationMs = endDate.getTime() - startDate.getTime();
const beforeStart = new Date(startDate.getTime() - durationMs);
const treatmentAsinSet = getExperimentAsins(experiment.asins || [], salesData);
const treatmentWeekly = aggregateWeeklyMetrics(treatmentAsinSet, salesData, experiment.marketplace);
const hasControlGroup = (experiment.control_asins || []).length > 0;
if (!hasControlGroup) {
// Without control group, counterfactual = flat line at pre-treatment average
const startTs = startDate.getTime();
const beforeStartTs = beforeStart.getTime();
const beforeData = treatmentWeekly.filter(w => w.timestamp >= beforeStartTs && w.timestamp < startTs);
const preAvg = avgMetric(beforeData, metric);
return treatmentWeekly.map(w => ({
week: w.week,
timestamp: w.timestamp,
actual: Math.round(getMetricValue(w, metric) * 100) / 100,
counterfactual: Math.round(preAvg * 100) / 100,
}));
}
const controlAsinSet = getExperimentAsins(experiment.control_asins, salesData);
const controlWeekly = aggregateWeeklyMetrics(controlAsinSet, salesData, experiment.marketplace);
const startTs = startDate.getTime();
const beforeStartTs = beforeStart.getTime();
const tBeforeData = treatmentWeekly.filter(w => w.timestamp >= beforeStartTs && w.timestamp < startTs);
const cBeforeData = controlWeekly.filter(w => w.timestamp >= beforeStartTs && w.timestamp < startTs);
const tPreAvg = avgMetric(tBeforeData, metric);
const cPreAvg = avgMetric(cBeforeData, metric);
// Build a map of control weekly values
const controlMap = new Map<string, number>();
for (const w of controlWeekly) {
controlMap.set(w.week, getMetricValue(w, metric));
}
return treatmentWeekly.map(w => {
const actual = getMetricValue(w, metric);
const controlVal = controlMap.get(w.week) ?? cPreAvg;
// Counterfactual: treatment pre-avg + (control current - control pre-avg)
const counterfactual = tPreAvg + (controlVal - cPreAvg);
return {
week: w.week,
timestamp: w.timestamp,
actual: Math.round(actual * 100) / 100,
counterfactual: Math.round(counterfactual * 100) / 100,
};
});
}
+53 -126
View File
@@ -5,12 +5,13 @@ import {
ActiveExperiment,
ExperimentType,
ExperimentStatus,
ExperimentVerdict,
CombinedKPIs,
} from '../types';
import { CombinedKPIs } from '../types';
const API_BASE = '/api/experiments';
// ============ Helper Functions ============
// ============ ASIN Resolution ============
export const getExperimentAsins = (experimentAsins: string[], salesData: CombinedKPIs[]): Set<string> => {
const explicitAsins = new Set<string>();
@@ -59,7 +60,6 @@ export const updateExperiment = async (
id: string,
updates: Partial<Experiment>
): Promise<Experiment> => {
try {
const response = await fetch(`${API_BASE}?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
@@ -76,10 +76,6 @@ export const updateExperiment = async (
}
return response.json();
} catch (err: any) {
console.error('API Error in updateExperiment:', err);
throw err;
}
};
export const deleteExperiment = async (id: string): Promise<void> => {
@@ -95,9 +91,7 @@ export const deleteExperiment = async (id: string): Promise<void> => {
export const getExperiment = async (id: string): Promise<Experiment | null> => {
const response = await fetch(`${API_BASE}?id=${id}`);
if (!response.ok) return null;
return response.json();
};
@@ -141,7 +135,6 @@ export const listExperiments = async (
const data = await response.json();
// Transform to ExperimentListItem
return data.map((exp: any) => {
const today = new Date();
const startDate = new Date(exp.start_date);
@@ -161,11 +154,14 @@ export const listExperiments = async (
status: exp.status,
asin_count: exp.asins?.length || 0,
asins: exp.asins || [],
control_asin_count: exp.control_asins?.length || 0,
marketplace: exp.marketplace,
start_date: exp.start_date,
end_date: exp.end_date,
progress_percent: Math.round(progressPercent),
primary_metric: exp.primary_metric,
verdict: exp.verdict,
verdict_probability: exp.verdict_probability,
actual_lift_percent: exp.actual_lift_percent,
owner: exp.owner,
};
@@ -185,7 +181,6 @@ export const getActiveExperiments = 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' &&
exp.start_date <= today &&
@@ -203,7 +198,7 @@ export const getActiveExperiments = async (
: undefined;
const activeExp: ActiveExperiment = {
asin: '', // placeholder
asin: '',
experiment_id: exp.id,
experiment_name: exp.name,
type: exp.type,
@@ -223,120 +218,7 @@ export const getActiveExperiments = async (
return map;
};
// ============ Performance Calculations ============
export const calculateExperimentPerformance = async (
experiment: Experiment,
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 parseLocalDate = (dateStr?: string) => {
if (!dateStr) return new Date();
const [y, m, d] = dateStr.split('T')[0].split('-');
return new Date(Number(y), Number(m) - 1, Number(d));
};
const startDate = parseLocalDate(experiment.start_date);
const endDate = experiment.end_date ? parseLocalDate(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);
const baselineEnd = startDate;
// Filter sales data for experiment ASINs
const asinSet = getExperimentAsins(experiment.asins, salesData);
const baselineData = salesData.filter(r => {
const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7);
const mkt = (r.marketplace || (r as any).customer || '').toLowerCase();
const isMarket = !experiment.marketplace || experiment.marketplace === 'All' || mkt.includes(experiment.marketplace.toLowerCase());
return isMarket && asinSet.has(r.asin.toUpperCase()) &&
recordDate >= baselineStart && recordDate < baselineEnd;
});
const experimentData = salesData.filter(r => {
const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7);
const mkt = (r.marketplace || (r as any).customer || '').toLowerCase();
const isMarket = !experiment.marketplace || experiment.marketplace === 'All' || mkt.includes(experiment.marketplace.toLowerCase());
return isMarket && asinSet.has(r.asin.toUpperCase()) &&
recordDate >= startDate && recordDate <= endDate;
});
const baseline_units = baselineData.reduce((sum, r) => sum + (r.unitsTotal ?? (r as any).units ?? 0), 0);
const baseline_revenue = baselineData.reduce((sum, r) => sum + (r.salesTotal ?? (r as any).sellOut ?? 0), 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 experiment_units = experimentData.reduce((sum, r) => sum + (r.unitsTotal ?? (r as any).units ?? 0), 0);
const experiment_revenue = experimentData.reduce((sum, r) => sum + (r.salesTotal ?? (r as any).sellOut ?? 0), 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: CombinedKPIs[]
): Promise<Experiment> => {
const experiment = await getExperiment(experimentId);
if (!experiment) throw new Error('Experiment not found');
const performance = await calculateExperimentPerformance(experiment, salesData);
return updateExperiment(experimentId, {
...performance,
});
};
// ============ Helper Functions ============
function getMonthNumber(monthStr: string): number {
const months: Record<string, number> = {
'Jan': 1, 'Feb': 2, 'Mar': 3, 'Apr': 4, 'May': 5, 'Jun': 6,
'Jul': 7, 'Aug': 8, 'Sep': 9, 'Oct': 10, 'Nov': 11, 'Dec': 12
};
return months[monthStr.split('-')[0]] || 1;
}
// ============ Display Helpers ============
export const getExperimentStatusColor = (status: ExperimentStatus): string => {
switch (status) {
@@ -354,6 +236,7 @@ export const getExperimentTypeColor = (type: ExperimentType): string => {
case 'advertising': return 'bg-purple-500/20 text-purple-400 border-purple-500/30';
case 'content': return 'bg-pink-500/20 text-pink-400 border-pink-500/30';
case 'promotion': return 'bg-orange-500/20 text-orange-400 border-orange-500/30';
case 'seo': return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/30';
default: return 'bg-slate-500/20 text-slate-400';
}
};
@@ -364,6 +247,50 @@ export const getExperimentIcon = (type: ExperimentType): string => {
case 'advertising': return '📢';
case 'content': return '📝';
case 'promotion': return '🏷️';
case 'seo': return '🔍';
default: return '🧪';
}
};
export const getVerdictColor = (verdict?: ExperimentVerdict): string => {
switch (verdict) {
case 'winner': return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
case 'loser': return 'bg-red-500/15 text-red-400 border-red-500/30';
case 'inconclusive': return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
default: return 'bg-slate-800/50 text-slate-500 border-slate-700';
}
};
export const getVerdictLabel = (verdict?: ExperimentVerdict): string => {
switch (verdict) {
case 'winner': return 'Winner';
case 'loser': return 'Loser';
case 'inconclusive': return 'Inconclusive';
default: return 'Pending';
}
};
export const METRIC_LABELS: Record<string, string> = {
units: 'Units Sold',
sessions: 'Sessions',
cvr: 'Conversion Rate',
ctr: 'Click-Through Rate',
roas: 'ROAS',
revenue: 'Revenue',
acos: 'ACOS',
};
export const formatMetricValue = (value: number, metric: string): string => {
switch (metric) {
case 'cvr':
case 'ctr':
case 'acos':
return `${value.toFixed(1)}%`;
case 'roas':
return `${value.toFixed(2)}x`;
case 'revenue':
return `${value.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
default:
return value.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 });
}
};
+37 -5
View File
@@ -281,9 +281,31 @@ export interface VendorCSVRow {
}
// Experiment Tracking Types
export type ExperimentType = 'pricing' | 'advertising' | 'content' | 'promotion';
export type ExperimentType = 'pricing' | 'advertising' | 'content' | 'promotion' | 'seo';
export type ExperimentStatus = 'planned' | 'active' | 'completed' | 'paused';
export type ExperimentMetric = 'units' | 'revenue' | 'acos' | 'ctr' | 'cvr' | 'bsr';
export type ExperimentMetric = 'units' | 'sessions' | 'cvr' | 'ctr' | 'roas' | 'revenue' | 'acos';
export type ExperimentVerdict = 'winner' | 'loser' | 'inconclusive';
export interface ExperimentChangeAnnotation {
field: string;
before_value: string;
after_value: string;
}
export interface DiDMetricResult {
treatment_before: number;
treatment_after: number;
control_before: number;
control_after: number;
did_estimate: number;
lift_percent: number;
posterior_prob_positive: number;
}
export interface DifferenceInDifferencesResult {
metrics: Record<string, DiDMetricResult>;
computed_at: string;
}
export interface Experiment {
id: string;
@@ -292,6 +314,7 @@ export interface Experiment {
type: ExperimentType;
status: ExperimentStatus;
asins: string[];
control_asins: string[];
marketplace: string;
start_date: string;
end_date?: string;
@@ -300,23 +323,27 @@ export interface Experiment {
hypothesis?: string;
primary_metric: ExperimentMetric;
target_lift_percent?: number;
changes: ExperimentChangeAnnotation[];
// Results
// Legacy results (kept for backward compat)
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;
// DiD results
did_results?: DifferenceInDifferencesResult;
verdict?: ExperimentVerdict;
verdict_probability?: number;
learnings?: string;
owner?: string;
}
@@ -326,12 +353,14 @@ export interface ExperimentCreateInput {
description?: string;
type: ExperimentType;
asins: string[];
control_asins: string[];
marketplace: string;
start_date: string;
end_date?: string;
hypothesis?: string;
primary_metric: ExperimentMetric;
target_lift_percent?: number;
changes: ExperimentChangeAnnotation[];
owner?: string;
}
@@ -342,11 +371,14 @@ export interface ExperimentListItem {
status: ExperimentStatus;
asin_count: number;
asins: string[];
control_asin_count: number;
marketplace: string;
start_date: string;
end_date?: string;
progress_percent: number;
primary_metric: ExperimentMetric;
verdict?: ExperimentVerdict;
verdict_probability?: number;
actual_lift_percent?: number;
owner?: string;
}