mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:45:23 +02:00
feat: complete experiments tab analytics optimization with modular kpis and sparklines
This commit is contained in:
@@ -961,6 +961,7 @@ const App: React.FC = () => {
|
||||
setPreselectedMarketplace(filters.customer[0] || '');
|
||||
setShowCreateExperiment(true);
|
||||
}}
|
||||
salesData={rawData}
|
||||
/>
|
||||
</div>
|
||||
</Suspense>
|
||||
|
||||
+199
-18
@@ -1,7 +1,8 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { Experiment, ExperimentCreateInput, ExperimentType, ExperimentStatus, ExperimentMetric } from '../types';
|
||||
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { Experiment, ExperimentCreateInput, ExperimentType, ExperimentStatus, ExperimentMetric, CombinedKPIs } from '../types';
|
||||
import { getExperiment, updateExperiment, deleteExperiment, calculateExperimentPerformance } from '../services/experiments';
|
||||
import { ExperimentStatusBadge, ExperimentTypeBadge } from './ExperimentBadge';
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartsTooltip, ResponsiveContainer, ReferenceLine } from 'recharts';
|
||||
|
||||
interface ExperimentDetailProps {
|
||||
experimentId: string | null;
|
||||
@@ -63,6 +64,64 @@ const ExperimentDetail: React.FC<ExperimentDetailProps> = ({ experimentId, onClo
|
||||
}
|
||||
};
|
||||
|
||||
const chartData = useMemo(() => {
|
||||
if (!experiment || !salesData.length) return [];
|
||||
|
||||
const asinSet = new Set(experiment.asins.map(a => a.toUpperCase()));
|
||||
const weeklyMap = new Map<string, any>();
|
||||
|
||||
salesData.forEach(r => {
|
||||
const asin = (r.asin || '').toUpperCase();
|
||||
if (!asinSet.has(asin)) return;
|
||||
|
||||
const weekNum = r.week || 1;
|
||||
const yearStr = r.year || new Date().getFullYear();
|
||||
const weekStr = `${yearStr}-W${String(weekNum).padStart(2, '0')}`;
|
||||
|
||||
if (!weeklyMap.has(weekStr)) {
|
||||
const d = new Date(yearStr, 0, 1 + (weekNum - 1) * 7);
|
||||
weeklyMap.set(weekStr, {
|
||||
name: weekStr,
|
||||
timestamp: d.getTime(),
|
||||
units: 0,
|
||||
revenue: 0,
|
||||
gv: 0,
|
||||
spend: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const w = weeklyMap.get(weekStr);
|
||||
w.units += r.unitsTotal || 0;
|
||||
w.revenue += r.salesTotal || 0;
|
||||
w.gv += r.glanceViews || 0;
|
||||
w.spend += r.cost || 0;
|
||||
});
|
||||
|
||||
const arr = Array.from(weeklyMap.values()).map(w => ({
|
||||
...w,
|
||||
cvr: w.gv > 0 ? (w.units / w.gv) * 100 : 0,
|
||||
acos: w.revenue > 0 ? (w.spend / w.revenue) * 100 : 0
|
||||
})).sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return arr.slice(-16); // Let's keep the last 16 weeks to ensure enough window before and during
|
||||
}, [experiment, salesData]);
|
||||
|
||||
const getChartMetricColor = (metric: string) => {
|
||||
switch (metric) {
|
||||
case 'cvr': return '#34d399'; // emerald
|
||||
case 'gv': return '#a78bfa'; // purple
|
||||
case 'acos': return '#fb7185'; // rose
|
||||
case 'revenue': return '#fbbf24'; // amber
|
||||
default: return '#818cf8'; // indigo for units
|
||||
}
|
||||
};
|
||||
|
||||
const formatChartMetric = (val: number, metric: string) => {
|
||||
if (metric === 'cvr' || metric === 'acos' || metric === 'ctr') return `${val.toFixed(1)}%`;
|
||||
if (metric === 'revenue' || metric === 'cost') return `€${Math.round(val).toLocaleString('de-DE')}`;
|
||||
return Math.round(val).toLocaleString('de-DE');
|
||||
};
|
||||
|
||||
if (!experimentId) return null;
|
||||
|
||||
return (
|
||||
@@ -135,30 +194,45 @@ const ExperimentDetail: React.FC<ExperimentDetailProps> = ({ experimentId, onClo
|
||||
) : experiment ? (
|
||||
<div className="space-y-6">
|
||||
{/* Performance Cards */}
|
||||
{(experiment.experiment_units !== undefined || experiment.baseline_units !== undefined) && (
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{(experiment.experiment_units !== undefined) && (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{experiment.type === 'pricing' && (
|
||||
<>
|
||||
<MetricDeltaCard title="Conversion Rate" baseline={experiment.baseline_cvr} experimentVal={experiment.experiment_cvr} format="percent" />
|
||||
<MetricDeltaCard title="Total Revenue" baseline={experiment.baseline_revenue} experimentVal={experiment.experiment_revenue} format="currency" />
|
||||
</>
|
||||
)}
|
||||
{experiment.type === 'advertising' && (
|
||||
<>
|
||||
<MetricDeltaCard title="Glance Views" baseline={experiment.baseline_gv} experimentVal={experiment.experiment_gv} format="number" />
|
||||
<MetricDeltaCard title="ACOS" baseline={experiment.baseline_acos} experimentVal={experiment.experiment_acos} format="percent" invertColors={true} />
|
||||
</>
|
||||
)}
|
||||
{experiment.type === 'content' && (
|
||||
<>
|
||||
<MetricDeltaCard title="Glance Views (SEO)" baseline={experiment.baseline_gv} experimentVal={experiment.experiment_gv} format="number" />
|
||||
<MetricDeltaCard title="CVR (Persuasion)" baseline={experiment.baseline_cvr} experimentVal={experiment.experiment_cvr} format="percent" />
|
||||
</>
|
||||
)}
|
||||
{experiment.type === 'promotion' && (
|
||||
<>
|
||||
<MetricDeltaCard title="Units Sold" baseline={experiment.baseline_units} experimentVal={experiment.experiment_units} format="number" />
|
||||
<MetricDeltaCard title="Revenue" baseline={experiment.baseline_revenue} experimentVal={experiment.experiment_revenue} format="currency" />
|
||||
</>
|
||||
)}
|
||||
|
||||
<PerformanceCard
|
||||
label="Baseline Units"
|
||||
value={experiment.baseline_units?.toLocaleString() || '—'}
|
||||
color="slate"
|
||||
/>
|
||||
<PerformanceCard
|
||||
label="Experiment Units"
|
||||
value={experiment.experiment_units?.toLocaleString() || '—'}
|
||||
color="indigo"
|
||||
/>
|
||||
<PerformanceCard
|
||||
label="Lift"
|
||||
label="Primary Target Lift"
|
||||
value={experiment.actual_lift_percent !== undefined
|
||||
? `${experiment.actual_lift_percent >= 0 ? '+' : ''}${experiment.actual_lift_percent}%`
|
||||
: '—'}
|
||||
color={experiment.actual_lift_percent && experiment.actual_lift_percent >= 0 ? 'emerald' : 'red'}
|
||||
color={experiment.actual_lift_percent !== undefined && experiment.actual_lift_percent >= 0 ? 'emerald' : 'red'}
|
||||
/>
|
||||
<PerformanceCard
|
||||
label="Statistical Significance"
|
||||
label="Significance"
|
||||
value={experiment.statistical_significance
|
||||
? `${(experiment.statistical_significance * 100).toFixed(1)}%`
|
||||
: '—'}
|
||||
: 'Pending'}
|
||||
color="purple"
|
||||
/>
|
||||
</div>
|
||||
@@ -174,6 +248,76 @@ const ExperimentDetail: React.FC<ExperimentDetailProps> = ({ experimentId, onClo
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Dynamic Metric Chart */}
|
||||
{chartData.length > 0 && (
|
||||
<div className="bg-slate-800/30 border border-slate-700 rounded-xl p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-sm font-semibold text-slate-300">
|
||||
Timeline Impact ({experiment.primary_metric.toUpperCase()})
|
||||
</h3>
|
||||
</div>
|
||||
<div className="h-48 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#334155" vertical={false} />
|
||||
<XAxis
|
||||
dataKey="name"
|
||||
stroke="#94a3b8"
|
||||
fontSize={10}
|
||||
tickMargin={8}
|
||||
tickFormatter={(val) => val.split('-')[1]} // Just show W12
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#94a3b8"
|
||||
fontSize={10}
|
||||
tickFormatter={(val) => formatChartMetric(val, experiment.primary_metric)}
|
||||
width={60}
|
||||
/>
|
||||
<RechartsTooltip
|
||||
contentStyle={{ backgroundColor: '#0f172a', borderColor: '#334155', borderRadius: '8px', fontSize: '12px' }}
|
||||
labelStyle={{ color: '#94a3b8', marginBottom: '4px' }}
|
||||
formatter={(value: number) => {
|
||||
if (experiment.primary_metric === 'cvr') return [`${value.toFixed(1)}%`, 'CVR'];
|
||||
if (experiment.primary_metric === 'acos') return [`${value.toFixed(1)}%`, 'ACOS'];
|
||||
if (experiment.primary_metric === 'revenue') return [`€${value.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`, 'Rev'];
|
||||
return [value.toLocaleString('de-DE'), 'Units'];
|
||||
}}
|
||||
/>
|
||||
{/* Reference Line for Start Date */}
|
||||
<ReferenceLine
|
||||
x={(() => {
|
||||
const dDate = new Date(experiment.start_date);
|
||||
const jan1 = new Date(dDate.getFullYear(), 0, 1);
|
||||
const dayOfYear = Math.floor((dDate.getTime() - jan1.getTime()) / 86400000) + 1;
|
||||
const wN = Math.ceil((dayOfYear + jan1.getDay()) / 7);
|
||||
return `${dDate.getFullYear()}-W${String(wN).padStart(2, '0')}`;
|
||||
})()}
|
||||
stroke="#e2e8f0"
|
||||
strokeDasharray="3 3"
|
||||
label={{ position: 'top', value: 'Started', fill: '#e2e8f0', fontSize: 10 }}
|
||||
/>
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={
|
||||
experiment.primary_metric === 'revenue' ? 'revenue' :
|
||||
experiment.primary_metric === 'acos' ? 'acos' :
|
||||
experiment.primary_metric === 'cvr' ? 'cvr' :
|
||||
experiment.primary_metric === 'bsr' ? 'units' :
|
||||
experiment.primary_metric === 'ctr' ? 'gv' :
|
||||
'units' // Default fallback mapping
|
||||
}
|
||||
stroke={getChartMetricColor(experiment.primary_metric)}
|
||||
origin="auto"
|
||||
strokeWidth={3}
|
||||
dot={{ fill: '#1e293b', r: 4, strokeWidth: 2 }}
|
||||
activeDot={{ r: 6, fill: getChartMetricColor(experiment.primary_metric), strokeWidth: 0 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details Grid */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* Left Column */}
|
||||
@@ -399,6 +543,43 @@ const PerformanceCard: React.FC<{ label: string; value: string; color: string }>
|
||||
);
|
||||
};
|
||||
|
||||
// Metric Delta Card Component
|
||||
const MetricDeltaCard: React.FC<{
|
||||
title: string;
|
||||
baseline: number | undefined;
|
||||
experimentVal: number | undefined;
|
||||
format?: 'number' | 'currency' | 'percent';
|
||||
invertColors?: boolean;
|
||||
}> = ({ title, baseline, experimentVal, format = 'number', invertColors = false }) => {
|
||||
if (baseline === undefined || experimentVal === undefined) return null;
|
||||
const delta = baseline > 0 ? ((experimentVal - baseline) / baseline) * 100 : 0;
|
||||
|
||||
const isPositive = invertColors ? delta <= 0 : delta >= 0;
|
||||
const colorClass = isPositive ? 'text-emerald-400' : 'text-red-400';
|
||||
const bgClass = isPositive ? 'bg-emerald-500/10 border-emerald-500/30' : 'bg-red-500/10 border-red-500/30';
|
||||
|
||||
const formatter = (val: number) => {
|
||||
if (format === 'percent') return `${val}%`;
|
||||
if (format === 'currency') return `€${val.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
|
||||
return val.toLocaleString('de-DE');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`p-4 rounded-xl border ${bgClass}`}>
|
||||
<div className="text-xs font-semibold text-slate-300 mb-2">{title}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-end justify-between">
|
||||
<div className="text-2xl font-bold text-white">{formatter(experimentVal)}</div>
|
||||
<div className={`text-sm font-bold ${colorClass}`}>
|
||||
{delta > 0 ? '+' : ''}{delta.toFixed(1)}%
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-slate-500 font-medium">vs {formatter(baseline)} baseline avg</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Detail Section Component
|
||||
const DetailSection: React.FC<{ title: string; children: React.ReactNode }> = ({ title, children }) => (
|
||||
<div className="bg-slate-800/30 border border-slate-700 rounded-xl p-4">
|
||||
|
||||
@@ -1,15 +1,92 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import { ExperimentListItem, ExperimentType, ExperimentStatus } from '../types';
|
||||
import { ExperimentListItem, ExperimentType, ExperimentStatus, CombinedKPIs } from '../types';
|
||||
import { listExperiments, deleteExperiment, getExperimentStatusColor, getExperimentTypeColor, getExperimentIcon } from '../services/experiments';
|
||||
import MultiSelectDropdown from './MultiSelectDropdown';
|
||||
import { ExperimentBadge, ExperimentStatusBadge, ExperimentTypeBadge } from './ExperimentBadge';
|
||||
import { LineChart, Line, ResponsiveContainer, YAxis } from 'recharts';
|
||||
|
||||
const getChartMetricColor = (metric: string) => {
|
||||
switch (metric) {
|
||||
case 'cvr': return '#34d399'; // emerald
|
||||
case 'gv': return '#a78bfa'; // purple
|
||||
case 'acos': return '#fb7185'; // rose
|
||||
case 'revenue': return '#fbbf24'; // amber
|
||||
default: return '#818cf8'; // indigo for units
|
||||
}
|
||||
};
|
||||
|
||||
const ExperimentSparkline: React.FC<{ experiment: ExperimentListItem; salesData: CombinedKPIs[] }> = ({ experiment, salesData }) => {
|
||||
const chartData = useMemo(() => {
|
||||
if (!experiment.asins || !salesData.length) return [];
|
||||
|
||||
const asinSet = new Set(experiment.asins.map(a => a.toUpperCase()));
|
||||
const weeklyMap = new Map<string, any>();
|
||||
|
||||
salesData.forEach(r => {
|
||||
const asin = (r.asin || '').toUpperCase();
|
||||
if (!asinSet.has(asin)) return;
|
||||
|
||||
const weekNum = r.week || 1;
|
||||
const yearStr = r.year || new Date().getFullYear();
|
||||
const weekStr = `${yearStr}-W${String(weekNum).padStart(2, '0')}`;
|
||||
|
||||
if (!weeklyMap.has(weekStr)) {
|
||||
const d = new Date(yearStr, 0, 1 + (weekNum - 1) * 7);
|
||||
weeklyMap.set(weekStr, {
|
||||
name: weekStr,
|
||||
timestamp: d.getTime(),
|
||||
units: 0,
|
||||
revenue: 0,
|
||||
gv: 0,
|
||||
spend: 0,
|
||||
});
|
||||
}
|
||||
|
||||
const w = weeklyMap.get(weekStr);
|
||||
w.units += r.unitsTotal || 0;
|
||||
w.revenue += r.salesTotal || 0;
|
||||
w.gv += r.glanceViews || 0;
|
||||
w.spend += r.cost || 0;
|
||||
});
|
||||
|
||||
const arr = Array.from(weeklyMap.values()).map(w => ({
|
||||
...w,
|
||||
cvr: w.gv > 0 ? (w.units / w.gv) * 100 : 0,
|
||||
acos: w.revenue > 0 ? (w.spend / w.revenue) * 100 : 0
|
||||
})).sort((a, b) => a.timestamp - b.timestamp);
|
||||
|
||||
return arr.slice(-12);
|
||||
}, [experiment, salesData]);
|
||||
|
||||
if (chartData.length < 2) return <div className="h-8 w-16 bg-slate-800/50 rounded animate-pulse" />;
|
||||
|
||||
const metric = experiment.primary_metric || 'units';
|
||||
|
||||
return (
|
||||
<div className="h-10 w-24">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chartData}>
|
||||
<YAxis domain={['dataMin', 'dataMax']} hide />
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={metric === 'bsr' ? 'units' : metric === 'ctr' ? 'gv' : metric}
|
||||
stroke={getChartMetricColor(metric)}
|
||||
strokeWidth={2}
|
||||
dot={false}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface ExperimentsViewProps {
|
||||
onOpenDetail: (experimentId: string) => void;
|
||||
onOpenCreate: () => void;
|
||||
salesData?: CombinedKPIs[];
|
||||
}
|
||||
|
||||
const ExperimentsView: React.FC<ExperimentsViewProps> = ({ onOpenDetail, onOpenCreate }) => {
|
||||
const ExperimentsView: React.FC<ExperimentsViewProps> = ({ onOpenDetail, onOpenCreate, salesData = [] }) => {
|
||||
const [experiments, setExperiments] = useState<ExperimentListItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -192,7 +269,7 @@ const ExperimentsView: React.FC<ExperimentsViewProps> = ({ onOpenDetail, onOpenC
|
||||
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">ASINs</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Duration</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Progress</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Lift</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Impact</th>
|
||||
<th className="text-left text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Owner</th>
|
||||
<th className="text-right text-xs font-medium text-slate-400 uppercase tracking-wider px-4 py-3">Actions</th>
|
||||
</tr>
|
||||
@@ -241,13 +318,21 @@ const ExperimentsView: React.FC<ExperimentsViewProps> = ({ onOpenDetail, onOpenC
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
{salesData.length > 0 && <ExperimentSparkline experiment={exp} salesData={salesData} />}
|
||||
<div className="w-16">
|
||||
{exp.actual_lift_percent !== undefined ? (
|
||||
<span className={`text-sm font-medium ${exp.actual_lift_percent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
<span className={`text-sm font-bold ${exp.actual_lift_percent >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
|
||||
{exp.actual_lift_percent >= 0 ? '+' : ''}{exp.actual_lift_percent}%
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-sm text-slate-500">—</span>
|
||||
<span className="text-sm text-slate-500 font-medium">—</span>
|
||||
)}
|
||||
<div className="text-[10px] text-slate-500 capitalize leading-none mt-0.5" style={{ color: getChartMetricColor(exp.primary_metric || 'units') }}>
|
||||
{exp.primary_metric || 'units'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-slate-400">{exp.owner || '—'}</span>
|
||||
@@ -303,4 +388,6 @@ const StatCard: React.FC<{ label: string; value: string | number; color: string
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
|
||||
export default ExperimentsView;
|
||||
|
||||
+42
-12
@@ -6,7 +6,7 @@ import {
|
||||
ExperimentType,
|
||||
ExperimentStatus,
|
||||
} from '../types';
|
||||
import { SalesRecord } from '../types';
|
||||
import { CombinedKPIs } from '../types';
|
||||
|
||||
const API_BASE = '/api/experiments';
|
||||
|
||||
@@ -123,10 +123,12 @@ export const listExperiments = async (
|
||||
type: exp.type,
|
||||
status: exp.status,
|
||||
asin_count: exp.asins?.length || 0,
|
||||
asins: exp.asins || [],
|
||||
marketplace: exp.marketplace,
|
||||
start_date: exp.start_date,
|
||||
end_date: exp.end_date,
|
||||
progress_percent: Math.round(progressPercent),
|
||||
primary_metric: exp.primary_metric,
|
||||
actual_lift_percent: exp.actual_lift_percent,
|
||||
owner: exp.owner,
|
||||
};
|
||||
@@ -191,12 +193,18 @@ export const getActiveExperimentsForASINs = async (
|
||||
|
||||
export const calculateExperimentPerformance = async (
|
||||
experiment: Experiment,
|
||||
salesData: SalesRecord[]
|
||||
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 startDate = new Date(experiment.start_date);
|
||||
@@ -211,38 +219,60 @@ export const calculateExperimentPerformance = async (
|
||||
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`);
|
||||
const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7);
|
||||
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`);
|
||||
const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7);
|
||||
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 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;
|
||||
|
||||
const actual_lift_percent = baseline_units > 0
|
||||
? ((experiment_units - baseline_units) / baseline_units) * 100
|
||||
: 0;
|
||||
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;
|
||||
}
|
||||
|
||||
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: SalesRecord[]
|
||||
salesData: CombinedKPIs[]
|
||||
): Promise<Experiment> => {
|
||||
const experiment = await getExperiment(experimentId);
|
||||
if (!experiment) throw new Error('Experiment not found');
|
||||
|
||||
@@ -304,8 +304,16 @@ export interface Experiment {
|
||||
// Results
|
||||
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;
|
||||
|
||||
@@ -333,10 +341,12 @@ export interface ExperimentListItem {
|
||||
type: ExperimentType;
|
||||
status: ExperimentStatus;
|
||||
asin_count: number;
|
||||
asins: string[];
|
||||
marketplace: string;
|
||||
start_date: string;
|
||||
end_date?: string;
|
||||
progress_percent: number;
|
||||
primary_metric: ExperimentMetric;
|
||||
actual_lift_percent?: number;
|
||||
owner?: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user