mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 13:55:23 +02:00
Add complete Experiments tracking system
New Features: - Experiments tab with full CRUD operations - Create experiments by ASIN or product line groups - Track pricing, advertising, content, and promotion experiments - Performance analytics with baseline vs experiment comparison - Visual experiment badges in Weekly Sales grid - Experiment detail view with metrics and learnings Technical Changes: - Add Supabase experiments table migration - New services/experiments.ts for CRUD + calculations - New components: ExperimentsView, ExperimentDetail, ExperimentForm, ExperimentBadge - Integrate experiment indicators in WeeklyGrid - Add navigation tab (desktop + mobile) - Type definitions in types.ts Usage: 1. Run SQL migration in Supabase 2. Navigate to Experiments tab 3. Create experiments with ASINs, dates, hypothesis 4. View performance lift after completion 5. See active experiments marked in Weekly Sales Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
co-authored by
Qwen-Coder
parent
a4919691b2
commit
f93d92da0b
@@ -0,0 +1,301 @@
|
||||
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[]>();
|
||||
|
||||
for (const asin of asins) {
|
||||
const experiments = (data || [])
|
||||
.filter((exp: any) => exp.asins?.includes(asin))
|
||||
.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;
|
||||
|
||||
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;
|
||||
});
|
||||
|
||||
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 '🧪';
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user