From 9a115ff5bebe3ac07e291cd7caa12a42a0ad8e6a Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Fri, 20 Feb 2026 10:58:51 +0100 Subject: [PATCH] feat: add Experiment Tracker to Weekly Sales tab Log weekly actions (SEO, pricing, ads, etc.) per product line and visualize their impact with a Recharts line chart showing experiment markers. Includes a collapsible experiments panel with auto-computed delta % (3-week before vs after comparison) and editable status tracking. Data persisted in localStorage. Co-Authored-By: Claude Opus 4.6 --- components/ExperimentTracker.tsx | 600 +++++++++++++++++++++++++++++++ components/WeeklyGrid.tsx | 4 + 2 files changed, 604 insertions(+) create mode 100644 components/ExperimentTracker.tsx diff --git a/components/ExperimentTracker.tsx b/components/ExperimentTracker.tsx new file mode 100644 index 0000000..42ade8a --- /dev/null +++ b/components/ExperimentTracker.tsx @@ -0,0 +1,600 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import { + LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, + ReferenceDot +} from 'recharts'; +import { WeeklyPivotRow } from '../services/dataProcessor'; + +// --- Types --- + +export interface Experiment { + id: string; + week: string; // "2026-08" matching YYYY-WW format used in pivot data + product_line: string; + action_type: 'SEO' | 'Price' | 'Advertising' | 'Images' | 'A+ Content' | 'Variants' | 'Stock' | 'Other'; + description: string; + expected_impact: 'More Sales' | 'Better Visibility' | 'Lower Costs'; + status: 'in_progress' | 'success' | 'failed'; + created_at: string; +} + +type ActionType = Experiment['action_type']; +type ExpectedImpact = Experiment['expected_impact']; +type ExperimentStatus = Experiment['status']; + +const ACTION_TYPES: ActionType[] = ['SEO', 'Price', 'Advertising', 'Images', 'A+ Content', 'Variants', 'Stock', 'Other']; +const EXPECTED_IMPACTS: ExpectedImpact[] = ['More Sales', 'Better Visibility', 'Lower Costs']; +const STATUSES: ExperimentStatus[] = ['in_progress', 'success', 'failed']; + +const ACTION_COLORS: Record = { + 'SEO': '#3b82f6', + 'Price': '#f59e0b', + 'Advertising': '#f43f5e', + 'Images': '#a855f7', + 'A+ Content': '#10b981', + 'Variants': '#14b8a6', + 'Stock': '#f97316', + 'Other': '#64748b', +}; + +const ACTION_BG_CLASSES: Record = { + 'SEO': 'bg-blue-500/20 text-blue-400 border-blue-500/30', + 'Price': 'bg-amber-500/20 text-amber-400 border-amber-500/30', + 'Advertising': 'bg-rose-500/20 text-rose-400 border-rose-500/30', + 'Images': 'bg-purple-500/20 text-purple-400 border-purple-500/30', + 'A+ Content': 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', + 'Variants': 'bg-teal-500/20 text-teal-400 border-teal-500/30', + 'Stock': 'bg-orange-500/20 text-orange-400 border-orange-500/30', + 'Other': 'bg-slate-500/20 text-slate-400 border-slate-500/30', +}; + +const STATUS_CLASSES: Record = { + 'in_progress': 'bg-blue-500/20 text-blue-400 border-blue-500/30', + 'success': 'bg-emerald-500/20 text-emerald-400 border-emerald-500/30', + 'failed': 'bg-red-500/20 text-red-400 border-red-500/30', +}; + +const STATUS_LABELS: Record = { + 'in_progress': 'In Progress', + 'success': 'Success', + 'failed': 'Failed', +}; + +const STORAGE_KEY = 'craze_experiments'; + +// --- Storage helpers --- + +const loadExperiments = (): Experiment[] => { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw ? JSON.parse(raw) : []; + } catch { + return []; + } +}; + +const saveExperiments = (experiments: Experiment[]) => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(experiments)); +}; + +// --- Current ISO week helper --- + +const getCurrentWeek = (): string => { + const now = new Date(); + const jan1 = new Date(now.getFullYear(), 0, 1); + const dayOfYear = Math.floor((now.getTime() - jan1.getTime()) / 86400000) + 1; + const weekNum = Math.ceil((dayOfYear + jan1.getDay()) / 7); + return `${now.getFullYear()}-${String(weekNum).padStart(2, '0')}`; +}; + +// --- Props --- + +interface ExperimentTrackerProps { + rows: WeeklyPivotRow[]; + weeks: string[]; // sorted descending (most recent first) + primaryMetric: 'units' | 'revenue'; +} + +// --- Custom dot for experiment markers --- + +const ExperimentDot: React.FC = (props) => { + const { cx, cy, experiments } = props; + if (!experiments || experiments.length === 0 || !cx || !cy) return null; + + const color = ACTION_COLORS[experiments[0].action_type as ActionType] || '#64748b'; + + return ( + + + + {experiments.length > 1 && ( + + +{experiments.length - 1} + + )} + + ); +}; + +// --- Custom chart tooltip --- + +const ChartTooltipContent: React.FC = ({ active, payload, label, experiments }) => { + if (!active || !payload || !payload.length) return null; + + const weekExperiments = experiments?.filter((e: Experiment) => e.week === label) || []; + + return ( +
+
+ Week {label?.split('-')[1]}/{label?.split('-')[0]?.slice(-2)} +
+ {payload.map((entry: any, i: number) => ( +
+ {entry.name} + + {entry.name === 'Revenue' ? `€${entry.value?.toLocaleString('de-DE')}` : entry.value?.toLocaleString('de-DE')} + +
+ ))} + {weekExperiments.length > 0 && ( +
+
Experiments
+ {weekExperiments.map((exp: Experiment) => ( +
+ +
+ + {exp.action_type} + + {exp.product_line} +

{exp.description}

+
+
+ ))} +
+ )} +
+ ); +}; + +// --- Main Component --- + +const ExperimentTracker: React.FC = ({ rows, weeks, primaryMetric }) => { + const [experiments, setExperiments] = useState(loadExperiments); + const [showForm, setShowForm] = useState(false); + const [showPanel, setShowPanel] = useState(true); + const [editingStatus, setEditingStatus] = useState(null); + + // Form state + const [formWeek, setFormWeek] = useState(getCurrentWeek); + const [formLine, setFormLine] = useState(''); + const [formAction, setFormAction] = useState('SEO'); + const [formDesc, setFormDesc] = useState(''); + const [formImpact, setFormImpact] = useState('More Sales'); + + // Available product lines from data + const productLines = useMemo(() => { + const lines = new Set(rows.map(r => r.line).filter(Boolean)); + return Array.from(lines).sort(); + }, [rows]); + + // Chart data: aggregate totals by week (ascending order for chart) + const chartData = useMemo(() => { + const reversedWeeks = [...weeks].reverse(); // ascending for chart + return reversedWeeks.map(week => { + let units = 0; + let revenue = 0; + let spend = 0; + for (const row of rows) { + units += row.unitsByWeek[week] || 0; + revenue += row.revenueByWeek[week] || 0; + spend += row.spendByWeek[week] || 0; + } + return { week, units, revenue, spend }; + }); + }, [rows, weeks]); + + // Weeks with experiments (for ReferenceDots) + const weekExperimentMap = useMemo(() => { + const map = new Map(); + for (const exp of experiments) { + const existing = map.get(exp.week) || []; + existing.push(exp); + map.set(exp.week, existing); + } + return map; + }, [experiments]); + + // Compute delta % for an experiment + const computeDelta = useCallback((exp: Experiment): { baseline: number | null; post: number | null; delta: number | null } => { + const ascWeeks = [...weeks].reverse(); + const weekIdx = ascWeeks.indexOf(exp.week); + if (weekIdx === -1) return { baseline: null, post: null, delta: null }; + + const getWeekTotal = (w: string) => { + let total = 0; + for (const row of rows) { + if (row.line !== exp.product_line) continue; + total += primaryMetric === 'units' + ? (row.unitsByWeek[w] || 0) + : (row.revenueByWeek[w] || 0); + } + return total; + }; + + // 3 weeks before + const beforeWeeks = ascWeeks.slice(Math.max(0, weekIdx - 3), weekIdx); + // 3 weeks after + const afterWeeks = ascWeeks.slice(weekIdx + 1, weekIdx + 4); + + if (beforeWeeks.length === 0) return { baseline: null, post: null, delta: null }; + + const baselineSum = beforeWeeks.reduce((sum, w) => sum + getWeekTotal(w), 0); + const baselineAvg = baselineSum / beforeWeeks.length; + + if (afterWeeks.length === 0) return { baseline: baselineAvg, post: null, delta: null }; + + const postSum = afterWeeks.reduce((sum, w) => sum + getWeekTotal(w), 0); + const postAvg = postSum / afterWeeks.length; + + const delta = baselineAvg > 0 ? ((postAvg - baselineAvg) / baselineAvg) * 100 : null; + + return { baseline: baselineAvg, post: postAvg, delta }; + }, [rows, weeks, primaryMetric]); + + // Persist experiments + const updateExperiments = useCallback((updated: Experiment[]) => { + setExperiments(updated); + saveExperiments(updated); + }, []); + + const handleSubmit = useCallback(() => { + if (!formLine || !formDesc.trim()) return; + + const newExp: Experiment = { + id: `exp_${Date.now()}`, + week: formWeek, + product_line: formLine, + action_type: formAction, + description: formDesc.trim(), + expected_impact: formImpact, + status: 'in_progress', + created_at: new Date().toISOString(), + }; + + updateExperiments([...experiments, newExp]); + setShowForm(false); + setFormDesc(''); + setFormWeek(getCurrentWeek()); + }, [formWeek, formLine, formAction, formDesc, formImpact, experiments, updateExperiments]); + + const handleStatusChange = useCallback((id: string, newStatus: ExperimentStatus) => { + updateExperiments(experiments.map(e => e.id === id ? { ...e, status: newStatus } : e)); + setEditingStatus(null); + }, [experiments, updateExperiments]); + + const handleDelete = useCallback((id: string) => { + updateExperiments(experiments.filter(e => e.id !== id)); + }, [experiments, updateExperiments]); + + // Available weeks for the form dropdown + const availableWeeks = useMemo(() => [...weeks].slice(0, 20), [weeks]); + + return ( +
+ {/* Header + Log Action Button */} +
+
+
+ + + +
+
+

Experiment Tracker

+

{experiments.length} experiments logged

+
+
+ +
+ + {/* Chart */} + {chartData.length > 0 && ( +
+
+ Total {primaryMetric === 'units' ? 'Units' : 'Revenue'} by Week + {experiments.length > 0 && ( + + — {experiments.length} experiment{experiments.length !== 1 ? 's' : ''} marked + + )} +
+ + + + `${w.split('-')[1]}/${w.split('-')[0].slice(-2)}`} + /> + primaryMetric === 'revenue' ? `€${(v / 1000).toFixed(0)}k` : v.toLocaleString('de-DE')} + /> + } + cursor={{ stroke: '#6366f1', strokeWidth: 1, strokeDasharray: '4 4' }} + /> + + + {/* Experiment markers */} + {chartData.map((point) => { + const exps = weekExperimentMap.get(point.week); + if (!exps || exps.length === 0) return null; + return ( + } + /> + ); + })} + + +
+ )} + + {/* Log Action Modal */} + {showForm && ( +
setShowForm(false)}> +
e.stopPropagation()}> +
+

Log Experiment

+ +
+ +
+ {/* Week */} +
+ + +
+ + {/* Product Line */} +
+ + +
+ + {/* Action Type */} +
+ +
+ {ACTION_TYPES.map(type => ( + + ))} +
+
+ + {/* Description */} +
+ +