Files
CrazeAnalytix/services/experiments.ts
Christian Vidal WolfandClaude Opus 4.6 24df5935cc 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>
2026-02-23 15:42:40 +01:00

297 lines
8.6 KiB
TypeScript

import {
Experiment,
ExperimentCreateInput,
ExperimentListItem,
ActiveExperiment,
ExperimentType,
ExperimentStatus,
ExperimentVerdict,
CombinedKPIs,
} from '../types';
const API_BASE = '/api/experiments';
// ============ ASIN Resolution ============
export const getExperimentAsins = (experimentAsins: string[], salesData: CombinedKPIs[]): Set<string> => {
const explicitAsins = new Set<string>();
const lines = new Set<string>();
(experimentAsins || []).forEach(a => {
const val = (a || '').trim().toUpperCase();
if (val.startsWith('LINE:')) {
lines.add(val.substring(5).trim());
} else if (val) {
explicitAsins.add(val);
}
});
const asinSet = new Set<string>(explicitAsins);
if (lines.size > 0 && salesData) {
salesData.forEach(r => {
const line = (r.line || '').trim().toUpperCase();
if (line && lines.has(line)) {
if (r.asin) asinSet.add(r.asin.toUpperCase());
}
});
}
return asinSet;
};
// ============ CRUD Operations ============
export const createExperiment = async (input: ExperimentCreateInput): Promise<Experiment> => {
const response = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create experiment');
}
return response.json();
};
export const updateExperiment = async (
id: string,
updates: Partial<Experiment>
): Promise<Experiment> => {
const response = await fetch(`${API_BASE}?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
let errText = await response.text();
try {
const errJson = JSON.parse(errText);
errText = errJson.error || errText;
} catch (e) { }
throw new Error(errText);
}
return response.json();
};
export const deleteExperiment = async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}?id=${id}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete experiment');
}
};
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();
};
export const listExperiments = async (
filters?: {
status?: ExperimentStatus[];
type?: ExperimentType[];
marketplace?: string[];
asin?: string;
dateFrom?: string;
dateTo?: string;
}
): Promise<ExperimentListItem[]> => {
const params = new URLSearchParams();
if (filters?.status?.length) {
filters.status.forEach(s => params.append('status', s));
}
if (filters?.type?.length) {
filters.type.forEach(t => params.append('type', t));
}
if (filters?.marketplace?.length) {
filters.marketplace.forEach(m => params.append('marketplace', m));
}
if (filters?.asin) {
params.append('asin', filters.asin);
}
if (filters?.dateFrom) {
params.append('dateFrom', filters.dateFrom);
}
if (filters?.dateTo) {
params.append('dateTo', filters.dateTo);
}
const response = await fetch(`${API_BASE}?${params.toString()}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to list experiments');
}
const data = await response.json();
return data.map((exp: any) => {
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();
const elapsedDays = today.getTime() - startDate.getTime();
progressPercent = Math.min(100, Math.max(0, (elapsedDays / totalDays) * 100));
}
return {
id: exp.id,
name: exp.name,
type: exp.type,
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,
};
});
};
export const getActiveExperiments = async (
salesData: CombinedKPIs[]
): Promise<Map<string, ActiveExperiment[]>> => {
const response = await fetch(`${API_BASE}?status=active`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch active experiments');
}
const data = await response.json();
const today = new Date().toISOString().split('T')[0];
const activeExperiments = data.filter((exp: any) =>
exp.status === 'active' &&
exp.start_date <= today &&
(!exp.end_date || exp.end_date >= today)
);
const map = new Map<string, ActiveExperiment[]>();
for (const exp of activeExperiments) {
const expAsins = getExperimentAsins(exp.asins || [], salesData);
const endDate = exp.end_date ? new Date(exp.end_date) : null;
const daysRemaining = endDate
? Math.ceil((endDate.getTime() - new Date().getTime()) / (1000 * 60 * 60 * 24))
: undefined;
const activeExp: ActiveExperiment = {
asin: '',
experiment_id: exp.id,
experiment_name: exp.name,
type: exp.type,
status: exp.status,
start_date: exp.start_date,
end_date: exp.end_date,
days_remaining: daysRemaining,
};
expAsins.forEach(asin => {
const asinList = map.get(asin) || [];
asinList.push({ ...activeExp, asin });
map.set(asin, asinList);
});
}
return map;
};
// ============ Display Helpers ============
export const getExperimentStatusColor = (status: ExperimentStatus): string => {
switch (status) {
case 'active': return 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30';
case 'planned': return 'bg-amber-500/20 text-amber-400 border-amber-500/30';
case 'completed': return 'bg-slate-500/20 text-slate-400 border-slate-500/30';
case 'paused': return 'bg-red-500/20 text-red-400 border-red-500/30';
default: return 'bg-slate-500/20 text-slate-400';
}
};
export const getExperimentTypeColor = (type: ExperimentType): string => {
switch (type) {
case 'pricing': return 'bg-blue-500/20 text-blue-400 border-blue-500/30';
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';
}
};
export const getExperimentIcon = (type: ExperimentType): string => {
switch (type) {
case 'pricing': return '💰';
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 });
}
};