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
+99 -12
View File
@@ -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);
@@ -57,7 +134,7 @@ const ExperimentsView: React.FC<ExperimentsViewProps> = ({ onOpenDetail, onOpenC
const handleDelete = async (id: string, e: React.MouseEvent) => {
e.stopPropagation();
if (!confirm('Are you sure you want to delete this experiment?')) return;
try {
await deleteExperiment(id);
loadExperiments();
@@ -78,7 +155,7 @@ const ExperimentsView: React.FC<ExperimentsViewProps> = ({ onOpenDetail, onOpenC
active: experiments.filter(e => e.status === 'active').length,
planned: experiments.filter(e => e.status === 'planned').length,
avgLift: experiments.filter(e => e.actual_lift_percent !== undefined)
.reduce((sum, e) => sum + (e.actual_lift_percent || 0), 0) /
.reduce((sum, e) => sum + (e.actual_lift_percent || 0), 0) /
Math.max(1, experiments.filter(e => e.actual_lift_percent !== undefined).length),
}), [experiments]);
@@ -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">
{exp.actual_lift_percent !== undefined ? (
<span className={`text-sm font-medium ${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>
)}
<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-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 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;