2026-02-20 19:31:44 +01:00
|
|
|
import {
|
|
|
|
|
Experiment,
|
|
|
|
|
ExperimentCreateInput,
|
|
|
|
|
ExperimentListItem,
|
|
|
|
|
ActiveExperiment,
|
|
|
|
|
ExperimentType,
|
|
|
|
|
ExperimentStatus,
|
|
|
|
|
} from '../types';
|
2026-02-21 15:31:02 +01:00
|
|
|
import { CombinedKPIs } from '../types';
|
2026-02-20 19:31:44 +01:00
|
|
|
|
2026-02-21 00:21:57 +01:00
|
|
|
const API_BASE = '/api/experiments';
|
2026-02-20 19:31:44 +01:00
|
|
|
|
2026-02-21 19:26:45 +01:00
|
|
|
// ============ Helper Functions ============
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-20 19:31:44 +01:00
|
|
|
// ============ CRUD Operations ============
|
|
|
|
|
|
|
|
|
|
export const createExperiment = async (input: ExperimentCreateInput): Promise<Experiment> => {
|
2026-02-21 00:21:57 +01:00
|
|
|
const response = await fetch(API_BASE, {
|
|
|
|
|
method: 'POST',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(input),
|
|
|
|
|
});
|
2026-02-20 19:31:44 +01:00
|
|
|
|
2026-02-21 00:21:57 +01:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new Error(error.error || 'Failed to create experiment');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return response.json();
|
2026-02-20 19:31:44 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const updateExperiment = async (
|
|
|
|
|
id: string,
|
|
|
|
|
updates: Partial<Experiment>
|
|
|
|
|
): Promise<Experiment> => {
|
2026-02-21 21:56:35 +01:00
|
|
|
try {
|
|
|
|
|
const response = await fetch(`${API_BASE}?id=${id}`, {
|
|
|
|
|
method: 'PUT',
|
|
|
|
|
headers: { 'Content-Type': 'application/json' },
|
|
|
|
|
body: JSON.stringify(updates),
|
|
|
|
|
});
|
2026-02-20 19:31:44 +01:00
|
|
|
|
2026-02-21 21:56:35 +01:00
|
|
|
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();
|
|
|
|
|
} catch (err: any) {
|
|
|
|
|
console.error('API Error in updateExperiment:', err);
|
|
|
|
|
throw err;
|
2026-02-21 00:21:57 +01:00
|
|
|
}
|
2026-02-20 19:31:44 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const deleteExperiment = async (id: string): Promise<void> => {
|
2026-02-21 00:21:57 +01:00
|
|
|
const response = await fetch(`${API_BASE}?id=${id}`, {
|
|
|
|
|
method: 'DELETE',
|
|
|
|
|
});
|
2026-02-20 19:31:44 +01:00
|
|
|
|
2026-02-21 00:21:57 +01:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new Error(error.error || 'Failed to delete experiment');
|
|
|
|
|
}
|
2026-02-20 19:31:44 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const getExperiment = async (id: string): Promise<Experiment | null> => {
|
2026-02-21 00:21:57 +01:00
|
|
|
const response = await fetch(`${API_BASE}?id=${id}`);
|
2026-02-20 19:31:44 +01:00
|
|
|
|
2026-02-21 00:21:57 +01:00
|
|
|
if (!response.ok) return null;
|
|
|
|
|
|
|
|
|
|
return response.json();
|
2026-02-20 19:31:44 +01:00
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const listExperiments = async (
|
|
|
|
|
filters?: {
|
|
|
|
|
status?: ExperimentStatus[];
|
|
|
|
|
type?: ExperimentType[];
|
|
|
|
|
marketplace?: string[];
|
|
|
|
|
asin?: string;
|
|
|
|
|
dateFrom?: string;
|
|
|
|
|
dateTo?: string;
|
|
|
|
|
}
|
|
|
|
|
): Promise<ExperimentListItem[]> => {
|
2026-02-21 00:21:57 +01:00
|
|
|
const params = new URLSearchParams();
|
2026-02-20 19:31:44 +01:00
|
|
|
|
|
|
|
|
if (filters?.status?.length) {
|
2026-02-21 00:21:57 +01:00
|
|
|
filters.status.forEach(s => params.append('status', s));
|
2026-02-20 19:31:44 +01:00
|
|
|
}
|
|
|
|
|
if (filters?.type?.length) {
|
2026-02-21 00:21:57 +01:00
|
|
|
filters.type.forEach(t => params.append('type', t));
|
2026-02-20 19:31:44 +01:00
|
|
|
}
|
|
|
|
|
if (filters?.marketplace?.length) {
|
2026-02-21 00:21:57 +01:00
|
|
|
filters.marketplace.forEach(m => params.append('marketplace', m));
|
2026-02-20 19:31:44 +01:00
|
|
|
}
|
|
|
|
|
if (filters?.asin) {
|
2026-02-21 00:21:57 +01:00
|
|
|
params.append('asin', filters.asin);
|
2026-02-20 19:31:44 +01:00
|
|
|
}
|
|
|
|
|
if (filters?.dateFrom) {
|
2026-02-21 00:21:57 +01:00
|
|
|
params.append('dateFrom', filters.dateFrom);
|
2026-02-20 19:31:44 +01:00
|
|
|
}
|
|
|
|
|
if (filters?.dateTo) {
|
2026-02-21 00:21:57 +01:00
|
|
|
params.append('dateTo', filters.dateTo);
|
2026-02-20 19:31:44 +01:00
|
|
|
}
|
|
|
|
|
|
2026-02-21 00:21:57 +01:00
|
|
|
const response = await fetch(`${API_BASE}?${params.toString()}`);
|
2026-02-20 19:31:44 +01:00
|
|
|
|
2026-02-21 00:21:57 +01:00
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new Error(error.error || 'Failed to list experiments');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
2026-02-20 19:31:44 +01:00
|
|
|
|
|
|
|
|
// Transform to ExperimentListItem
|
2026-02-21 00:21:57 +01:00
|
|
|
return data.map((exp: any) => {
|
2026-02-20 19:31:44 +01:00
|
|
|
const today = new Date();
|
|
|
|
|
const startDate = new Date(exp.start_date);
|
|
|
|
|
const endDate = exp.end_date ? new Date(exp.end_date) : null;
|
2026-02-21 15:31:02 +01:00
|
|
|
|
2026-02-20 19:31:44 +01:00
|
|
|
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,
|
2026-02-21 15:31:02 +01:00
|
|
|
asins: exp.asins || [],
|
2026-02-20 19:31:44 +01:00
|
|
|
marketplace: exp.marketplace,
|
|
|
|
|
start_date: exp.start_date,
|
|
|
|
|
end_date: exp.end_date,
|
|
|
|
|
progress_percent: Math.round(progressPercent),
|
2026-02-21 15:31:02 +01:00
|
|
|
primary_metric: exp.primary_metric,
|
2026-02-20 19:31:44 +01:00
|
|
|
actual_lift_percent: exp.actual_lift_percent,
|
|
|
|
|
owner: exp.owner,
|
|
|
|
|
};
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 19:26:45 +01:00
|
|
|
export const getActiveExperiments = async (
|
|
|
|
|
salesData: CombinedKPIs[]
|
2026-02-20 19:31:44 +01:00
|
|
|
): Promise<Map<string, ActiveExperiment[]>> => {
|
2026-02-21 19:26:45 +01:00
|
|
|
const response = await fetch(`${API_BASE}?status=active`);
|
2026-02-21 00:21:57 +01:00
|
|
|
|
|
|
|
|
if (!response.ok) {
|
|
|
|
|
const error = await response.json();
|
|
|
|
|
throw new Error(error.error || 'Failed to fetch active experiments');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const data = await response.json();
|
2026-02-20 19:31:44 +01:00
|
|
|
const today = new Date().toISOString().split('T')[0];
|
2026-02-21 15:31:02 +01:00
|
|
|
|
2026-02-21 00:21:57 +01:00
|
|
|
// Filter experiments that are currently active
|
2026-02-21 15:31:02 +01:00
|
|
|
const activeExperiments = data.filter((exp: any) =>
|
|
|
|
|
exp.status === 'active' &&
|
2026-02-21 00:21:57 +01:00
|
|
|
exp.start_date <= today &&
|
|
|
|
|
(!exp.end_date || exp.end_date >= today)
|
|
|
|
|
);
|
2026-02-20 19:31:44 +01:00
|
|
|
|
|
|
|
|
const map = new Map<string, ActiveExperiment[]>();
|
2026-02-20 19:39:56 +01:00
|
|
|
|
2026-02-21 19:26:45 +01:00
|
|
|
for (const exp of activeExperiments) {
|
|
|
|
|
const expAsins = getExperimentAsins(exp.asins || [], salesData);
|
2026-02-20 19:39:56 +01:00
|
|
|
|
2026-02-21 19:26:45 +01:00
|
|
|
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;
|
2026-02-20 19:39:56 +01:00
|
|
|
|
2026-02-21 19:26:45 +01:00
|
|
|
const activeExp: ActiveExperiment = {
|
|
|
|
|
asin: '', // placeholder
|
|
|
|
|
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);
|
|
|
|
|
});
|
2026-02-20 19:31:44 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return map;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ============ Performance Calculations ============
|
|
|
|
|
|
|
|
|
|
export const calculateExperimentPerformance = async (
|
|
|
|
|
experiment: Experiment,
|
2026-02-21 15:31:02 +01:00
|
|
|
salesData: CombinedKPIs[]
|
2026-02-20 19:31:44 +01:00
|
|
|
): Promise<{
|
|
|
|
|
baseline_units: number;
|
|
|
|
|
baseline_revenue: number;
|
2026-02-21 15:31:02 +01:00
|
|
|
baseline_gv: number;
|
|
|
|
|
baseline_cvr: number;
|
|
|
|
|
baseline_acos: number;
|
2026-02-20 19:31:44 +01:00
|
|
|
experiment_units: number;
|
|
|
|
|
experiment_revenue: number;
|
2026-02-21 15:31:02 +01:00
|
|
|
experiment_gv: number;
|
|
|
|
|
experiment_cvr: number;
|
|
|
|
|
experiment_acos: number;
|
2026-02-20 19:31:44 +01:00
|
|
|
actual_lift_percent: number;
|
|
|
|
|
}> => {
|
2026-02-21 19:44:18 +01:00
|
|
|
const parseLocalDate = (dateStr?: string) => {
|
|
|
|
|
if (!dateStr) return new Date();
|
2026-02-21 19:37:07 +01:00
|
|
|
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();
|
2026-02-21 15:31:02 +01:00
|
|
|
|
2026-02-20 19:31:44 +01:00
|
|
|
// 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
|
2026-02-21 19:26:45 +01:00
|
|
|
const asinSet = getExperimentAsins(experiment.asins, salesData);
|
2026-02-21 15:31:02 +01:00
|
|
|
|
2026-02-20 19:31:44 +01:00
|
|
|
const baselineData = salesData.filter(r => {
|
2026-02-21 15:31:02 +01:00
|
|
|
const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7);
|
2026-02-20 19:31:44 +01:00
|
|
|
return asinSet.has(r.asin.toUpperCase()) &&
|
|
|
|
|
recordDate >= baselineStart && recordDate < baselineEnd;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const experimentData = salesData.filter(r => {
|
2026-02-21 15:31:02 +01:00
|
|
|
const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7);
|
2026-02-20 19:31:44 +01:00
|
|
|
return asinSet.has(r.asin.toUpperCase()) &&
|
|
|
|
|
recordDate >= startDate && recordDate <= endDate;
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-21 15:31:02 +01:00
|
|
|
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;
|
2026-02-20 19:31:44 +01:00
|
|
|
|
2026-02-21 15:31:02 +01:00
|
|
|
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;
|
|
|
|
|
}
|
2026-02-20 19:31:44 +01:00
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
baseline_units,
|
|
|
|
|
baseline_revenue,
|
2026-02-21 15:31:02 +01:00
|
|
|
baseline_gv,
|
|
|
|
|
baseline_cvr: Math.round(baseline_cvr * 10) / 10,
|
|
|
|
|
baseline_acos: Math.round(baseline_acos * 10) / 10,
|
2026-02-20 19:31:44 +01:00
|
|
|
experiment_units,
|
|
|
|
|
experiment_revenue,
|
2026-02-21 15:31:02 +01:00
|
|
|
experiment_gv,
|
|
|
|
|
experiment_cvr: Math.round(experiment_cvr * 10) / 10,
|
|
|
|
|
experiment_acos: Math.round(experiment_acos * 10) / 10,
|
2026-02-20 19:31:44 +01:00
|
|
|
actual_lift_percent: Math.round(actual_lift_percent * 10) / 10,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const updateExperimentResults = async (
|
|
|
|
|
experimentId: string,
|
2026-02-21 15:31:02 +01:00
|
|
|
salesData: CombinedKPIs[]
|
2026-02-20 19:31:44 +01:00
|
|
|
): 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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
|
|
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 '🏷️';
|
|
|
|
|
default: return '🧪';
|
|
|
|
|
}
|
|
|
|
|
};
|