Files
CrazeAnalytix/services/experiments.ts
T

302 lines
8.8 KiB
TypeScript
Raw Normal View History

2026-02-20 19:31:44 +01:00
import { createClient } from '@supabase/supabase-js';
import {
Experiment,
ExperimentCreateInput,
ExperimentListItem,
ActiveExperiment,
ExperimentType,
ExperimentStatus,
} from '../types';
import { SalesRecord } from '../types';
const supabaseUrl = process.env.SUPABASE_URL || '';
const supabaseServiceKey = process.env.SUPABASE_SERVICE_KEY || '';
const getSupabase = () => {
if (!supabaseUrl || !supabaseServiceKey) {
throw new Error('Supabase credentials not configured');
}
return createClient(supabaseUrl, supabaseServiceKey);
};
// ============ CRUD Operations ============
export const createExperiment = async (input: ExperimentCreateInput): Promise<Experiment> => {
const supabase = getSupabase();
const { data, error } = await supabase
.from('experiments')
.insert([{
...input,
status: 'planned',
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
}])
.select()
.single();
if (error) throw error;
return data as Experiment;
};
export const updateExperiment = async (
id: string,
updates: Partial<Experiment>
): Promise<Experiment> => {
const supabase = getSupabase();
const { data, error } = await supabase
.from('experiments')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) throw error;
return data as Experiment;
};
export const deleteExperiment = async (id: string): Promise<void> => {
const supabase = getSupabase();
const { error } = await supabase
.from('experiments')
.delete()
.eq('id', id);
if (error) throw error;
};
export const getExperiment = async (id: string): Promise<Experiment | null> => {
const supabase = getSupabase();
const { data, error } = await supabase
.from('experiments')
.select('*')
.eq('id', id)
.single();
if (error) return null;
return data as Experiment;
};
export const listExperiments = async (
filters?: {
status?: ExperimentStatus[];
type?: ExperimentType[];
marketplace?: string[];
asin?: string;
dateFrom?: string;
dateTo?: string;
}
): Promise<ExperimentListItem[]> => {
const supabase = getSupabase();
let query = supabase.from('experiments').select('*');
if (filters?.status?.length) {
query = query.in('status', filters.status);
}
if (filters?.type?.length) {
query = query.in('type', filters.type);
}
if (filters?.marketplace?.length) {
query = query.in('marketplace', filters.marketplace);
}
if (filters?.asin) {
query = query.contains('asins', [filters.asin]);
}
if (filters?.dateFrom) {
query = query.gte('start_date', filters.dateFrom);
}
if (filters?.dateTo) {
query = query.lte('end_date', filters.dateTo);
}
const { data, error } = await query.order('created_at', { ascending: false });
if (error) throw error;
// Transform to ExperimentListItem
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,
marketplace: exp.marketplace,
start_date: exp.start_date,
end_date: exp.end_date,
progress_percent: Math.round(progressPercent),
actual_lift_percent: exp.actual_lift_percent,
owner: exp.owner,
};
});
};
export const getActiveExperimentsForASINs = async (
asins: string[]
): Promise<Map<string, ActiveExperiment[]>> => {
const supabase = getSupabase();
const today = new Date().toISOString().split('T')[0];
const { data, error } = await supabase
.from('experiments')
.select('*')
.eq('status', 'active')
.lte('start_date', today)
.or(`end_date.is.null,end_date.gte.${today}`);
if (error) throw error;
const map = new Map<string, ActiveExperiment[]>();
2026-02-20 19:31:44 +01:00
for (const asin of asins) {
const experiments = (data || [])
.filter((exp: any) => Array.isArray(exp.asins) && exp.asins.includes(asin))
2026-02-20 19:31:44 +01:00
.map((exp: any) => {
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:31:44 +01:00
return {
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,
} as ActiveExperiment;
});
2026-02-20 19:31:44 +01:00
if (experiments.length > 0) {
map.set(asin, experiments);
}
}
return map;
};
// ============ Performance Calculations ============
export const calculateExperimentPerformance = async (
experiment: Experiment,
salesData: SalesRecord[]
): Promise<{
baseline_units: number;
baseline_revenue: number;
experiment_units: number;
experiment_revenue: 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);
const baselineEnd = startDate;
// 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`);
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`);
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 actual_lift_percent = baseline_units > 0
? ((experiment_units - baseline_units) / baseline_units) * 100
: 0;
return {
baseline_units,
baseline_revenue,
experiment_units,
experiment_revenue,
actual_lift_percent: Math.round(actual_lift_percent * 10) / 10,
};
};
export const updateExperimentResults = async (
experimentId: string,
salesData: SalesRecord[]
): 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 '🧪';
}
};