feat: complete experiments tab analytics optimization with modular kpis and sparklines

This commit is contained in:
Christian Vidal Wolf
2026-02-21 15:31:02 +01:00
parent 67e93f6653
commit d78a88afe1
5 changed files with 362 additions and 53 deletions
+199 -18
View File
@@ -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">