feat: remove MKT and Experiments tabs and all related code

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-06-12 08:29:47 +02:00
co-authored by Claude Sonnet 4.6
parent 9f9c0c2975
commit 76d901fcde
25 changed files with 11 additions and 4985 deletions
+3 -85
View File
@@ -6,14 +6,10 @@ import FilterBar from './components/FilterBar';
import AIChat from './components/AIChat';
import CrazeLogo from './components/CrazeLogo';
import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processBSRExcel, filterBsrData, isAllowedCustomer, isRealSale } from './services/dataProcessor';
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment, BSRRecord } from './types';
import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, BSRRecord } from './types';
import { queryGemini } from './services/geminiService';
import { ChartIcon, TableIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
import {
listExperiments,
getActiveExperiments,
} from './services/experiments';
// Lazy load heavy components for better initial performance
const DataGrid = lazy(() => import('./components/DataGrid'));
@@ -21,9 +17,6 @@ const WeeklyGrid = lazy(() => import('./components/WeeklyGrid'));
const AdsPerformance = lazy(() => import('./components/AdsPerformance'));
const ForecastView = lazy(() => import('./components/ForecastView'));
const VendorDataView = lazy(() => import('./components/VendorDataView'));
const ExperimentsView = lazy(() => import('./components/ExperimentsView'));
const MktDataView = lazy(() => import('./components/mkt/MktDataView'));
// Loading fallback component
const LoadingSpinner = () => (
@@ -51,7 +44,7 @@ const App: React.FC = () => {
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false);
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'ads' | 'forecast' | 'vendor' | 'experiments' | 'mkt'>('dashboard');
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'ads' | 'forecast' | 'vendor'>('dashboard');
const [forecastData, setForecastData] = useState<ProductForecastData[]>([]);
const [isChatOpen, setIsChatOpen] = useState(false);
const [activeUrl, setActiveUrl] = useState<string | null>(() => localStorage.getItem('craze_csv_url') || PERMANENT_DROPBOX_URL);
@@ -63,10 +56,6 @@ const App: React.FC = () => {
const [buyBoxLostMap, setBuyBoxLostMap] = useState<Map<string, { countries: string[]; reasons: Record<string, string> }>>(new Map());
const [bsrData, setBsrData] = useState<BSRRecord[]>([]);
// Experiments state
const [experimentMap, setExperimentMap] = useState<Map<string, ActiveExperiment[]>>(new Map());
// Experiment modal state removed — detail/create are now inline in ExperimentsView
// Modal State
const [isDataModalOpen, setIsDataModalOpen] = useState(false);
@@ -266,17 +255,6 @@ const App: React.FC = () => {
}
}, []);
const handleExperimentsFetch = useCallback(async () => {
try {
const expMap = await getActiveExperiments(rawData);
setExperimentMap(expMap);
console.log('[App] Loaded experiments for', expMap.size, 'ASINs');
} catch (error) {
console.error("Failed to fetch experiments", error);
}
}, [rawData]);
const handleForecastFetch = useCallback(async (sales: SalesRecord[], globalSales: SalesRecord[], activeFilters: FilterState) => {
try {
const isUK = activeFilters.customer.includes('Amazon UK');
@@ -356,11 +334,6 @@ const App: React.FC = () => {
setView('ads');
}, []);
// Create experiment for a specific line — now handled inline by ExperimentsView
const handleCreateExperimentForLine = useCallback((_line: string) => {
setView('experiments');
}, []);
// 1. Initial Load from Cache (IndexedDB) or Auto-Fetch Permanent URL
useEffect(() => {
const initApp = async () => {
@@ -418,7 +391,6 @@ const App: React.FC = () => {
handleStockFetch(),
handleVendorStockFetch(),
handleBuyBoxFetch(),
handleExperimentsFetch(),
handleBSRFetch()
]).catch(e => console.warn("Background fetch failed", e));
@@ -550,15 +522,6 @@ const App: React.FC = () => {
const filteredAdsData = useMemo(() => filterAdsData(adsData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode), [adsData, deferredFilters, globalAsinMetadata, stockMap, vendorStockMap, top50Mode]);
console.timeEnd('filteredAdsData');
const mktAdsData = useMemo(() => adsData.filter(ad => {
const countryMatch = deferredFilters.customer.length === 0
? true
: deferredFilters.customer.some(c => c.toUpperCase() === ad.country.toUpperCase());
const yearMatch = deferredFilters.year.length === 0 || deferredFilters.year.includes(ad.year.toString());
const weekMatch = deferredFilters.week.length === 0 || deferredFilters.week.includes(`W${ad.week}`);
return countryMatch && yearMatch && weekMatch;
}), [adsData, deferredFilters.customer, deferredFilters.year, deferredFilters.week]);
console.time('aggregatedData');
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
console.timeEnd('aggregatedData');
@@ -796,13 +759,6 @@ const App: React.FC = () => {
>
<TrendingIcon /> <span className="hidden lg:inline">Weekly Sales</span>
</button>
<button
onClick={() => setView('mkt')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'mkt' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<ChartIcon /> <span className="hidden lg:inline">MKT</span>
</button>
<button
onClick={() => setView('ads')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
@@ -824,14 +780,6 @@ const App: React.FC = () => {
>
<ChartIcon /> <span className="hidden lg:inline">BSR</span>
</button>
<button
onClick={() => setView('experiments')}
className={`flex items-center gap-2 px-3 py-2 rounded-md text-sm font-medium transition-all
${view === 'experiments' ? 'bg-slate-800 text-white shadow-sm ring-1 ring-white/10' : 'text-slate-400 hover:text-white hover:bg-slate-800/50'}`}
>
<span className="text-lg">🧪</span> <span className="hidden lg:inline">Experiments</span>
</button>
</div>
</div>
</div>
@@ -901,8 +849,6 @@ const App: React.FC = () => {
top50Ranking={top50Ranking2025}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
experimentMap={experimentMap}
onOpenExperiment={() => setView('experiments')}
/>
</div>
</Suspense>
@@ -964,30 +910,6 @@ const App: React.FC = () => {
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'experiments' ? '' : 'hidden'}>
<ExperimentsView
salesData={unfilteredCombinedData}
onExperimentsFetch={handleExperimentsFetch}
/>
</div>
</Suspense>
<Suspense fallback={<LoadingSpinner />}>
<div className={view === 'mkt' ? '' : 'hidden'}>
<MktDataView
rawData={filteredData}
adsData={mktAdsData}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
top50Mode={filters.customer.includes('Amazon UK') ? 'uk' : 'eu'}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
top50Ranking={top50Ranking2025}
/>
</div>
</Suspense>
</div>
</>
)}
@@ -998,16 +920,14 @@ const App: React.FC = () => {
{/* Mobile Bottom Navigation */}
<nav className="fixed bottom-0 left-0 right-0 z-50 bg-slate-950/95 backdrop-blur border-t border-border md:hidden pb-safe">
<div className="grid grid-cols-8 gap-0">
<div className="grid grid-cols-6 gap-0">
{([
{ key: 'dashboard' as const, icon: <ChartIcon />, label: 'Home' },
{ key: 'table' as const, icon: <TableIcon />, label: 'Grid' },
{ key: 'weekly' as const, icon: <TrendingIcon />, label: 'Weekly' },
{ key: 'ads' as const, icon: <MegaphoneIcon />, label: 'Ads' },
{ key: 'forecast' as const, icon: <ChartIcon />, label: 'Fc 26' },
{ key: 'mkt' as const, icon: <ChartIcon />, label: 'MKT' },
{ key: 'vendor' as const, icon: <ChartIcon />, label: 'BSR' },
{ key: 'experiments' as const, icon: <span className="text-lg">🧪</span>, label: 'Exp' },
]).map(({ key, icon, label }) => (
<button
key={key}
@@ -1051,8 +971,6 @@ const App: React.FC = () => {
)
}
{/* Experiment modals removed — detail/create are now inline in ExperimentsView */}
</div >
);
};
-119
View File
@@ -1,119 +0,0 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
import { createClient } from '@supabase/supabase-js';
import { Experiment, ExperimentCreateInput } from '../types';
const supabase = createClient(
process.env.SUPABASE_URL || '',
process.env.SUPABASE_SERVICE_KEY || '',
{
auth: {
autoRefreshToken: false,
persistSession: false
}
}
);
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') return res.status(200).end();
try {
// GET - List experiments or get single experiment
if (req.method === 'GET') {
const { id, status, type, marketplace, asin, dateFrom, dateTo } = req.query;
let query = supabase.from('experiments').select('*');
if (id) {
const { data, error } = await query.eq('id', id).single();
if (error) throw error;
return res.status(200).json(data);
}
// List with filters
if (status) {
const statuses = Array.isArray(status) ? status : [status];
query = query.in('status', statuses);
}
if (type) {
const types = Array.isArray(type) ? type : [type];
query = query.in('type', types);
}
if (marketplace) {
const markets = Array.isArray(marketplace) ? marketplace : [marketplace];
query = query.in('marketplace', markets);
}
if (asin) {
query = query.contains('asins', [asin]);
}
if (dateFrom) {
query = query.gte('start_date', dateFrom as string);
}
if (dateTo) {
query = query.lte('end_date', dateTo as string);
}
const { data, error } = await query.order('created_at', { ascending: false });
if (error) throw error;
return res.status(200).json(data || []);
}
// POST - Create experiment
if (req.method === 'POST') {
const input: ExperimentCreateInput = req.body;
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 res.status(201).json(data);
}
// PUT - Update experiment
if (req.method === 'PUT') {
const { id } = req.query;
const updates = req.body;
console.log('Update payload for id', id, ':', updates);
const { data, error } = await supabase
.from('experiments')
.update({ ...updates, updated_at: new Date().toISOString() })
.eq('id', id)
.select()
.single();
if (error) throw error;
return res.status(200).json(data);
}
// DELETE - Delete experiment
if (req.method === 'DELETE') {
const { id } = req.query;
const { error } = await supabase
.from('experiments')
.delete()
.eq('id', id);
if (error) throw error;
return res.status(200).json({ success: true });
}
return res.status(405).json({ error: 'Method not allowed' });
} catch (error: any) {
console.error('Experiments API error:', error);
return res.status(500).json({ error: error.message || 'Internal server error' });
}
}
-45
View File
@@ -1,45 +0,0 @@
import type { VercelRequest, VercelResponse } from '@vercel/node';
const FILE_URLS: Record<string, string> = {
deals: "https://www.dropbox.com/scl/fi/3mm5m4e04myhqt59piefs/Deals-Amazon-2025.xlsx?rlkey=6llr0xhs6di0c3d8o96ashzmv&st=ggu0z7hu&dl=1",
promos: "https://www.dropbox.com/scl/fi/ufhej9do9w839oyyfrpk3/Promos_Amazon_2025_all-countries.xlsx?rlkey=4foano9dt5h3sl58l35vqg89d&st=wu0t56wh&dl=1",
chargebacks: "https://www.dropbox.com/scl/fi/m2qr2cxhtgkc48acjrrgx/Amazon-Operational-Chargebacks-2025.xlsx?rlkey=vlrz4qfydsjtikgh0ylivocjq&st=zdhmjvaz&dl=1",
};
export default async function handler(req: VercelRequest, res: VercelResponse) {
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
return res.status(200).end();
}
const file = req.query.file as string;
const url = FILE_URLS[file];
if (!url) {
return res.status(400).json({ error: `Unknown file: "${file}". Valid options: deals, promos, chargebacks` });
}
try {
console.log(`[fetch-mkt-data] Fetching ${file} from Dropbox...`);
const response = await fetch(url, {
cache: 'no-store',
headers: { 'Pragma': 'no-cache', 'Cache-Control': 'no-cache' }
});
if (!response.ok) {
throw new Error(`Dropbox responded with ${response.status}`);
}
const buffer = await response.arrayBuffer();
console.log(`[fetch-mkt-data] Successfully fetched ${file}, size:`, buffer.byteLength);
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.status(200).send(Buffer.from(buffer));
} catch (error: any) {
console.error(`[fetch-mkt-data] Error fetching ${file}:`, error);
res.status(500).json({ error: error.message });
}
}
-115
View File
@@ -1,115 +0,0 @@
import React from 'react';
import { ActiveExperiment, ExperimentStatus } from '../types';
import { getExperimentStatusColor, getExperimentTypeColor, getExperimentIcon } from '../services/experiments';
interface ExperimentBadgeProps {
experiments: ActiveExperiment[];
onClick?: (experimentId: string) => void;
}
const getStatusBadge = (status: ExperimentStatus) => {
switch (status) {
case 'active': return '🟢';
case 'planned': return '🟡';
case 'completed': return '⚪';
case 'paused': return '⏸️';
}
};
export const ExperimentBadge: React.FC<ExperimentBadgeProps> = ({ experiments, onClick }) => {
if (!experiments || experiments.length === 0) return null;
const activeExp = experiments.find(e => e.status === 'active');
const plannedExp = experiments.find(e => e.status === 'planned');
// Priority: active > planned > past
const displayExp = activeExp || plannedExp || experiments[0];
const daysText = displayExp.days_remaining !== undefined
? displayExp.days_remaining > 0
? `${displayExp.days_remaining}d left`
: 'Ending soon'
: '';
return (
<div className="inline-flex items-center gap-1">
{experiments.length > 1 && (
<span className="text-[9px] text-slate-500 mr-1">+{experiments.length}</span>
)}
<button
onClick={(e) => {
e.stopPropagation();
onClick?.(displayExp.experiment_id);
}}
className={`
inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-medium
border ${getExperimentStatusColor(displayExp.status)}
hover:opacity-80 transition-opacity cursor-pointer
`}
title={`${displayExp.experiment_name}
Type: ${displayExp.type}
Start: ${displayExp.start_date}
End: ${displayExp.end_date || 'Ongoing'}
${daysText}`}
>
<span>{getExperimentIcon(displayExp.type)}</span>
<span className="hidden xl:inline truncate max-w-[80px]">{displayExp.experiment_name}</span>
{daysText && <span className="opacity-60">{daysText}</span>}
</button>
</div>
);
};
interface ExperimentStatusBadgeProps {
status: ExperimentStatus;
size?: 'sm' | 'md' | 'lg';
}
export const ExperimentStatusBadge: React.FC<ExperimentStatusBadgeProps> = ({ status, size = 'md' }) => {
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-[9px]',
md: 'px-2 py-1 text-xs',
lg: 'px-3 py-1.5 text-sm',
};
const statusLabels = {
active: 'Active',
planned: 'Planned',
completed: 'Completed',
paused: 'Paused',
};
return (
<span className={`inline-flex items-center gap-1 rounded-full font-medium border ${getExperimentStatusColor(status)} ${sizeClasses[size]}`}>
{getStatusBadge(status)} {statusLabels[status]}
</span>
);
};
interface ExperimentTypeBadgeProps {
type: string;
size?: 'sm' | 'md' | 'lg';
}
export const ExperimentTypeBadge: React.FC<ExperimentTypeBadgeProps> = ({ type, size = 'md' }) => {
const sizeClasses = {
sm: 'px-1.5 py-0.5 text-[9px]',
md: 'px-2 py-1 text-xs',
lg: 'px-3 py-1.5 text-sm',
};
const typeLabels: Record<string, string> = {
pricing: 'Pricing',
advertising: 'Advertising',
content: 'Content',
promotion: 'Promotion',
seo: 'SEO',
};
return (
<span className={`inline-flex items-center gap-1 rounded-full font-medium border ${getExperimentTypeColor(type as any)} ${sizeClasses[size]}`}>
<span>{getExperimentIcon(type as any)}</span>
{typeLabels[type as keyof typeof typeLabels] || type}
</span>
);
};
-634
View File
@@ -1,634 +0,0 @@
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;
marketplace: string; // e.g. "Amazon DE", "Amazon UK"
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<ActionType, string> = {
'SEO': '#3b82f6',
'Price': '#f59e0b',
'Advertising': '#f43f5e',
'Images': '#a855f7',
'A+ Content': '#10b981',
'Variants': '#14b8a6',
'Stock': '#f97316',
'Other': '#64748b',
};
const ACTION_BG_CLASSES: Record<ActionType, string> = {
'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<ExperimentStatus, string> = {
'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<ExperimentStatus, string> = {
'in_progress': 'In Progress',
'success': 'Success',
'failed': 'Failed',
};
const ALL_MARKETPLACES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES', 'Amazon UK'];
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';
customerFilters: string[]; // active marketplace filters from parent
}
// --- Custom dot for experiment markers ---
const ExperimentDot: React.FC<any> = (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 (
<g>
<circle cx={cx} cy={cy} r={10} fill={color} fillOpacity={0.25} stroke={color} strokeWidth={1.5} />
<circle cx={cx} cy={cy} r={4} fill={color} />
{experiments.length > 1 && (
<text x={cx + 12} y={cy - 4} fill={color} fontSize={9} fontWeight="bold">
+{experiments.length - 1}
</text>
)}
</g>
);
};
// --- Custom chart tooltip ---
const ChartTooltipContent: React.FC<any> = ({ active, payload, label, experiments }) => {
if (!active || !payload || !payload.length) return null;
const weekExperiments = experiments?.filter((e: Experiment) => e.week === label) || [];
return (
<div className="bg-slate-950 border border-white/20 rounded-xl shadow-2xl p-3 max-w-xs">
<div className="text-[10px] font-black text-slate-400 uppercase tracking-widest mb-2 pb-2 border-b border-white/10">
Week {label?.split('-')[1]}/{label?.split('-')[0]?.slice(-2)}
</div>
{payload.map((entry: any, i: number) => (
<div key={i} className="flex justify-between items-center gap-4 mb-1">
<span className="text-[10px] text-slate-500 font-bold">{entry.name}</span>
<span className="text-xs font-black" style={{ color: entry.color }}>
{entry.name === 'Revenue' ? `${entry.value?.toLocaleString('de-DE')}` : entry.value?.toLocaleString('de-DE')}
</span>
</div>
))}
{weekExperiments.length > 0 && (
<div className="mt-2 pt-2 border-t border-white/10 space-y-1.5">
<div className="text-[9px] font-black text-fuchsia-400 uppercase tracking-widest">Experiments</div>
{weekExperiments.map((exp: Experiment) => (
<div key={exp.id} className="flex items-start gap-2">
<span
className="w-2 h-2 rounded-full mt-1 flex-shrink-0"
style={{ backgroundColor: ACTION_COLORS[exp.action_type] }}
/>
<div>
<span className={`text-[9px] font-bold px-1.5 py-0.5 rounded border ${ACTION_BG_CLASSES[exp.action_type]}`}>
{exp.action_type}
</span>
<span className="text-[10px] text-white/70 ml-1.5">{exp.product_line}</span>
{exp.marketplace && <span className="text-[9px] text-slate-500 ml-1">({exp.marketplace})</span>}
<p className="text-[10px] text-slate-400 mt-0.5 leading-tight">{exp.description}</p>
</div>
</div>
))}
</div>
)}
</div>
);
};
// --- Main Component ---
const ExperimentTracker: React.FC<ExperimentTrackerProps> = ({ rows, weeks, primaryMetric, customerFilters }) => {
const [experiments, setExperiments] = useState<Experiment[]>(loadExperiments);
const [showForm, setShowForm] = useState(false);
const [showPanel, setShowPanel] = useState(true);
const [editingStatus, setEditingStatus] = useState<string | null>(null);
// Form state
const [formWeek, setFormWeek] = useState(getCurrentWeek);
const [formLine, setFormLine] = useState('');
const [formMarketplace, setFormMarketplace] = useState('');
const [formAction, setFormAction] = useState<ActionType>('SEO');
const [formDesc, setFormDesc] = useState('');
const [formImpact, setFormImpact] = useState<ExpectedImpact>('More Sales');
// Default marketplace to active filter when opening form
const defaultMarketplace = useMemo(() => {
if (customerFilters.length === 1) return customerFilters[0];
if (customerFilters.length > 0) return customerFilters[0];
return ALL_MARKETPLACES[0];
}, [customerFilters]);
// 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<string, Experiment[]>();
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;
if (exp.marketplace && row.customer !== exp.marketplace) 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 || !formMarketplace || !formDesc.trim()) return;
const newExp: Experiment = {
id: `exp_${Date.now()}`,
week: formWeek,
product_line: formLine,
marketplace: formMarketplace,
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, formMarketplace, 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 (
<div className="flex flex-col gap-3">
{/* Header + Log Action Button */}
<div className="flex items-center justify-between bg-slate-900 border border-white/10 p-3 px-4 rounded-xl">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-fuchsia-500 to-indigo-600 flex items-center justify-center shadow-lg shadow-fuchsia-500/20">
<svg className="w-4 h-4 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
</svg>
</div>
<div>
<h3 className="text-sm font-black text-white uppercase tracking-wider">Experiment Tracker</h3>
<p className="text-[10px] text-slate-500 font-bold">{experiments.length} experiments logged</p>
</div>
</div>
<button
onClick={() => { setFormMarketplace(defaultMarketplace); setShowForm(true); }}
className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 rounded-lg text-xs font-bold transition-colors border border-indigo-500/50 shadow-lg shadow-indigo-500/20"
>
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
</svg>
Log Action
</button>
</div>
{/* Chart */}
{chartData.length > 0 && (
<div className="bg-slate-900 border border-white/10 rounded-xl p-4 shadow-lg">
<div className="text-[10px] font-black text-slate-500 uppercase tracking-widest mb-3">
Total {primaryMetric === 'units' ? 'Units' : 'Revenue'} by Week
{experiments.length > 0 && (
<span className="ml-2 text-fuchsia-400">
{experiments.length} experiment{experiments.length !== 1 ? 's' : ''} marked
</span>
)}
</div>
<ResponsiveContainer width="100%" height={260}>
<LineChart data={chartData} margin={{ top: 10, right: 20, left: 10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#1e293b" vertical={false} />
<XAxis
dataKey="week"
stroke="#64748b"
tick={{ fontSize: 9, fontWeight: 'bold' }}
tickFormatter={(w: string) => `${w.split('-')[1]}/${w.split('-')[0].slice(-2)}`}
/>
<YAxis
stroke="#64748b"
tick={{ fontSize: 9 }}
tickFormatter={(v: number) => primaryMetric === 'revenue' ? `${(v / 1000).toFixed(0)}k` : v.toLocaleString('de-DE')}
/>
<Tooltip
content={<ChartTooltipContent experiments={experiments} />}
cursor={{ stroke: '#6366f1', strokeWidth: 1, strokeDasharray: '4 4' }}
/>
<Line
type="monotone"
dataKey={primaryMetric}
stroke="#6366f1"
strokeWidth={2.5}
dot={{ fill: '#6366f1', r: 3, strokeWidth: 0 }}
activeDot={{ r: 5, fill: '#818cf8', strokeWidth: 2, stroke: '#6366f1' }}
name={primaryMetric === 'units' ? 'Units' : 'Revenue'}
/>
<Line
type="monotone"
dataKey="spend"
stroke="#f43f5e"
strokeWidth={1.5}
strokeDasharray="4 3"
dot={false}
name="Ads Spend"
/>
{/* Experiment markers */}
{chartData.map((point) => {
const exps = weekExperimentMap.get(point.week);
if (!exps || exps.length === 0) return null;
return (
<ReferenceDot
key={point.week}
x={point.week}
y={point[primaryMetric]}
shape={<ExperimentDot experiments={exps} />}
/>
);
})}
</LineChart>
</ResponsiveContainer>
</div>
)}
{/* Log Action Modal */}
{showForm && (
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-black/60 backdrop-blur-sm" onClick={() => setShowForm(false)}>
<div className="bg-slate-900 border border-white/15 rounded-2xl shadow-2xl w-full max-w-lg mx-4 p-6" onClick={e => e.stopPropagation()}>
<div className="flex items-center justify-between mb-5">
<h3 className="text-base font-black text-white uppercase tracking-wider">Log Experiment</h3>
<button onClick={() => setShowForm(false)} className="text-slate-500 hover:text-white transition-colors">
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<div className="space-y-4">
{/* Week */}
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Week</label>
<select
value={formWeek}
onChange={e => setFormWeek(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
{availableWeeks.map(w => (
<option key={w} value={w}>Week {w.split('-')[1]}/{w.split('-')[0]} {w === getCurrentWeek() ? '(Current)' : ''}</option>
))}
</select>
</div>
{/* Product Line */}
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Product Line</label>
<select
value={formLine}
onChange={e => setFormLine(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Select product line...</option>
{productLines.map(line => (
<option key={line} value={line}>{line}</option>
))}
</select>
</div>
{/* Marketplace */}
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Marketplace</label>
<select
value={formMarketplace}
onChange={e => setFormMarketplace(e.target.value)}
className="w-full bg-slate-950 border border-white/10 rounded-lg px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
<option value="">Select marketplace...</option>
{ALL_MARKETPLACES.map(mk => (
<option key={mk} value={mk}>{mk}</option>
))}
</select>
</div>
{/* Action Type */}
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Action Type</label>
<div className="flex flex-wrap gap-1.5">
{ACTION_TYPES.map(type => (
<button
key={type}
onClick={() => setFormAction(type)}
className={`px-3 py-1.5 rounded-lg text-xs font-bold border transition-all ${formAction === type
? ACTION_BG_CLASSES[type] + ' ring-1 ring-white/20'
: 'bg-slate-800 text-slate-500 border-white/5 hover:border-white/15'
}`}
>
{type}
</button>
))}
</div>
</div>
{/* Description */}
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Description</label>
<textarea
value={formDesc}
onChange={e => setFormDesc(e.target.value)}
placeholder="e.g., Added keyword 'caja musical' to title..."
rows={3}
className="w-full bg-slate-950 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-slate-600 focus:outline-none focus:ring-2 focus:ring-indigo-500 resize-none"
/>
</div>
{/* Expected Impact */}
<div>
<label className="text-[10px] font-black text-slate-400 uppercase tracking-widest block mb-1.5">Expected Impact</label>
<div className="flex gap-2">
{EXPECTED_IMPACTS.map(impact => (
<button
key={impact}
onClick={() => setFormImpact(impact)}
className={`flex-1 px-3 py-2 rounded-lg text-xs font-bold border transition-all ${formImpact === impact
? 'bg-indigo-600/20 text-indigo-400 border-indigo-500/30 ring-1 ring-indigo-500/20'
: 'bg-slate-800 text-slate-500 border-white/5 hover:border-white/15'
}`}
>
{impact}
</button>
))}
</div>
</div>
</div>
{/* Submit */}
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-white/10">
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 rounded-lg text-xs font-bold text-slate-400 bg-slate-800 hover:bg-slate-700 border border-white/5 transition-colors"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={!formLine || !formMarketplace || !formDesc.trim()}
className="px-5 py-2 rounded-lg text-xs font-bold text-white bg-indigo-600 hover:bg-indigo-500 border border-indigo-500/50 shadow-lg shadow-indigo-500/20 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
Save Experiment
</button>
</div>
</div>
</div>
)}
{/* Experiments Panel */}
{experiments.length > 0 && (
<div className="bg-slate-900 border border-white/10 rounded-xl shadow-lg overflow-hidden">
<button
onClick={() => setShowPanel(!showPanel)}
className="w-full flex items-center justify-between p-3 px-4 hover:bg-white/[0.02] transition-colors"
>
<div className="flex items-center gap-2">
<span className="text-[10px] font-black text-slate-400 uppercase tracking-widest">
Experiments Log ({experiments.length})
</span>
</div>
<svg className={`w-4 h-4 text-slate-500 transition-transform ${showPanel ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{showPanel && (
<div className="overflow-x-auto border-t border-white/5">
<table className="w-full text-left border-collapse min-w-[900px]">
<thead>
<tr className="border-b border-white/10">
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Week</th>
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Marketplace</th>
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Product Line</th>
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Action</th>
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Description</th>
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Expected</th>
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest">Status</th>
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest text-right">Delta %</th>
<th className="p-2.5 px-4 text-[10px] font-black text-slate-500 uppercase tracking-widest w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{[...experiments].sort((a, b) => b.week.localeCompare(a.week)).map(exp => {
const { delta } = computeDelta(exp);
return (
<tr key={exp.id} className="hover:bg-white/[0.02] transition-colors">
<td className="p-2.5 px-4 text-xs font-bold text-white whitespace-nowrap">
W{exp.week.split('-')[1]}/{exp.week.split('-')[0].slice(-2)}
</td>
<td className="p-2.5 px-4">
<span className="text-[10px] text-sky-400 font-bold">{exp.marketplace || '—'}</span>
</td>
<td className="p-2.5 px-4">
<span className="text-[10px] text-fuchsia-400 font-bold uppercase tracking-widest">{exp.product_line}</span>
</td>
<td className="p-2.5 px-4">
<span className={`text-[10px] font-bold px-2 py-1 rounded border ${ACTION_BG_CLASSES[exp.action_type]}`}>
{exp.action_type}
</span>
</td>
<td className="p-2.5 px-4 text-xs text-white/70 max-w-[250px] truncate" title={exp.description}>
{exp.description}
</td>
<td className="p-2.5 px-4 text-[10px] text-slate-400 font-bold whitespace-nowrap">
{exp.expected_impact}
</td>
<td className="p-2.5 px-4">
{editingStatus === exp.id ? (
<select
value={exp.status}
onChange={e => handleStatusChange(exp.id, e.target.value as ExperimentStatus)}
onBlur={() => setEditingStatus(null)}
autoFocus
className="bg-slate-950 border border-white/10 rounded px-2 py-1 text-[10px] text-white focus:outline-none focus:ring-1 focus:ring-indigo-500"
>
{STATUSES.map(s => (
<option key={s} value={s}>{STATUS_LABELS[s]}</option>
))}
</select>
) : (
<button
onClick={() => setEditingStatus(exp.id)}
className={`text-[10px] font-bold px-2 py-1 rounded border cursor-pointer hover:ring-1 hover:ring-white/20 transition-all ${STATUS_CLASSES[exp.status]}`}
>
{STATUS_LABELS[exp.status]}
</button>
)}
</td>
<td className="p-2.5 px-4 text-right">
{delta !== null ? (
<span className={`text-xs font-black ${delta >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{delta >= 0 ? '+' : ''}{delta.toFixed(1)}%
</span>
) : (
<span className="text-[10px] text-slate-600 italic">Pending</span>
)}
</td>
<td className="p-2.5 px-4">
<button
onClick={() => handleDelete(exp.id)}
className="text-slate-600 hover:text-red-400 transition-colors"
title="Delete experiment"
>
<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" />
</svg>
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
);
};
export default ExperimentTracker;
File diff suppressed because it is too large Load Diff
+1 -20
View File
@@ -11,8 +11,6 @@ interface MetricDetailTooltipProps {
metricName: string;
metricColor: string;
formatValue?: (val: number) => string;
experimentDelta?: number | null;
baselineValue?: number | null;
}
/**
@@ -29,9 +27,7 @@ export const MetricDetailTooltip: React.FC<MetricDetailTooltipProps> = ({
yoyWeekLabel,
metricName,
metricColor,
formatValue,
experimentDelta,
baselineValue
formatValue,
}) => {
const [isVisible, setIsVisible] = useState(false);
@@ -100,21 +96,6 @@ export const MetricDetailTooltip: React.FC<MetricDetailTooltipProps> = ({
)}
</div>
{/* Experiment Baseline Delta (If active) */}
{typeof experimentDelta === 'number' && typeof baselineValue === 'number' && (
<div className="border-t border-fuchsia-500/20 pt-2 mt-2 bg-fuchsia-500/5 -mx-3 px-3 pb-1">
<div className="flex justify-between items-center mb-1">
<span className="text-[10px] text-fuchsia-400/80 font-bold">vs Pre-Experiment Baseline</span>
<span className={`text-xs font-black ${experimentDelta >= 0 ? 'text-emerald-400' : 'text-red-400'}`}>
{experimentDelta >= 0 ? '▲' : '▼'} {Math.abs(experimentDelta).toFixed(1)}%
</span>
</div>
<div className="flex justify-between items-center">
<span className="text-[9px] text-slate-500">Baseline Avg (4w)</span>
<span className="text-[10px] font-bold text-slate-400">{format(baselineValue)}</span>
</div>
</div>
)}
</div>
{/* Arrow */}
+1 -38
View File
@@ -11,9 +11,7 @@ import { VendorStockBadge } from './VendorStockBadge';
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons';
import { ExcelFilter } from './ExcelFilter';
import { ExperimentBadge } from './ExperimentBadge';
import { WeeklyRow } from './WeeklyRow';
import { ActiveExperiment } from '../types';
interface WeeklyGridProps {
data: CombinedKPIs[];
@@ -34,8 +32,6 @@ interface WeeklyGridProps {
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
top50Mode?: 'eu' | 'uk';
experimentMap?: Map<string, ActiveExperiment[]>;
onOpenExperiment?: (experimentId: string) => void;
}
type SortConfig = {
@@ -76,8 +72,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
top50Mode,
velocityMap,
buyBoxLostMap,
experimentMap,
onOpenExperiment
}) => {
// Pivot data - memoized
const { rows, weeks: allWeeks } = useMemo(() => pivotWeeklySalesData(data), [data]);
@@ -91,7 +85,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
const [growthFilterMode, setGrowthFilterMode] = useState<'all' | 'up' | 'down' | 'stable'>('all');
const [growthThreshold, setGrowthThreshold] = useState(10);
const [showOnlyTop50, setShowOnlyTop50] = useState(false);
const [showActiveExperiments, setShowActiveExperiments] = useState(false);
const [displayCount, setDisplayCount] = useState(50);
const [primaryMetric, setPrimaryMetric] = useState<'units' | 'revenue'>('units');
const scrollContainerRef = useRef<HTMLDivElement>(null);
@@ -215,15 +208,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
});
}
// Active Experiments Filter
if (showActiveExperiments && experimentMap) {
result = result.filter(r => {
const asin = r.asin.trim().toUpperCase();
const experiments = experimentMap.get(asin);
return experiments && experiments.length > 0;
});
}
// Column Filters (Excel-style) - using logic adapted from filterData
if (Object.keys(columnFilters).length > 0) {
(Object.entries(columnFilters) as [string, ColumnFilterCondition][]).forEach(([key, condition]) => {
@@ -343,7 +327,7 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
}
return result;
}, [rows, debouncedSearch, showOnlyTop50, showActiveExperiments, experimentMap, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]);
}, [rows, debouncedSearch, showOnlyTop50, top50Ranking, growthFilterMode, growthThreshold, sortConfig, weeks, top50Mode, wocFilter, velocityMap, vendorStockMap, numericFilters, columnFilters]);
const aggregateWoc = useMemo(() => {
if (!vendorStockMap || !velocityMap || filteredRows.length === 0) return null;
@@ -524,25 +508,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
</div>
)}
{/* Active Experiments Toggle */}
{experimentMap && experimentMap.size > 0 && (
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-2">
<button
onClick={() => setShowActiveExperiments(!showActiveExperiments)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-xs font-bold transition-all ${showActiveExperiments
? 'bg-fuchsia-600 text-white shadow-lg shadow-fuchsia-500/20'
: 'text-slate-400 hover:text-fuchsia-400'
}`}
title="Filter grid to show only ASINs with logged experiments"
>
<svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19.428 15.428a2 2 0 00-1.022-.547l-2.387-.477a6 6 0 00-3.86.517l-.318.158a6 6 0 01-3.86.517L6.05 15.21a2 2 0 00-1.806.547M8 4h8l-1 1v5.172a2 2 0 00.586 1.414l5 5c1.26 1.26.367 3.414-1.415 3.414H4.828c-1.782 0-2.674-2.154-1.414-3.414l5-5A2 2 0 009 10.172V5L8 4z" />
</svg>
Active Experiments
</button>
</div>
)}
{/* Metric Toggle */}
<div className="flex bg-slate-950/50 p-1 rounded-xl border border-white/10 shadow-sm ml-auto lg:ml-0">
<button
@@ -848,8 +813,6 @@ const WeeklyGrid: React.FC<WeeklyGridProps & { top50Mode: 'eu' | 'uk' }> = ({
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
primaryMetric={primaryMetric}
experimentMap={experimentMap}
onOpenExperiment={onOpenExperiment}
/>
))}
{displayCount < sortedRows.length && (
+5 -91
View File
@@ -1,10 +1,8 @@
import React from 'react';
import { WeeklyPivotRow } from '../services/dataProcessor';
import { ActiveExperiment } from '../types';
import { Top50Badge } from './Top50Badge';
import { StockBadge } from './StockBadge';
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
import { ExperimentBadge } from './ExperimentBadge';
import { VendorStockBadge } from './VendorStockBadge';
import { MetricDetailTooltip } from './MetricDetailTooltip';
@@ -26,61 +24,12 @@ interface WeeklyRowProps {
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
primaryMetric: 'units' | 'revenue';
experimentMap?: Map<string, ActiveExperiment[]>;
onOpenExperiment?: (experimentId: string) => void;
}
// Helper to get 4-week baseline prior to the start date
const getBaselineMetrics = (row: WeeklyPivotRow, weeks: string[], expStartDate: string) => {
const startDate = new Date(expStartDate);
const jan1 = new Date(startDate.getFullYear(), 0, 1);
const dayOfYear = Math.floor((startDate.getTime() - jan1.getTime()) / 86400000) + 1;
const weekNum = Math.ceil((dayOfYear + jan1.getDay()) / 7);
const startWeekStr = `${startDate.getFullYear()}-${String(weekNum).padStart(2, '0')}`;
const startIdx = weeks.indexOf(startWeekStr);
if (startIdx === -1) return null;
let u = 0, r = 0, g = 0, weeksCount = 0;
for (let i = 1; i <= 4; i++) {
const w = weeks[startIdx + i];
if (w) {
u += (row.unitsByWeek[w] || 0);
r += (row.revenueByWeek[w] || 0);
g += (row.gvByWeek?.[w] || 0);
weeksCount++;
}
}
if (weeksCount === 0) return null;
return {
units: u / weeksCount,
revenue: r / weeksCount,
gv: g / weeksCount,
cvr: g > 0 ? (u / g) * 100 : 0
};
};
// Helper to check if a week overlaps with an experiment's date range
const isWeekInExperiment = (weekKey: string, exp: ActiveExperiment): boolean => {
if (!weekKey || !weekKey.includes('-')) return false;
const [yearStr, weekStr] = weekKey.split('-');
const year = parseInt(yearStr);
const week = parseInt(weekStr);
const weekStart = new Date(year, 0, 1 + (week - 1) * 7);
const weekEnd = new Date(weekStart.getTime() + 6 * 86400000);
const expStart = new Date(exp.start_date);
const expEnd = exp.end_date ? new Date(exp.end_date) : new Date();
return weekStart <= expEnd && weekEnd >= expStart;
};
export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig,
renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap,
primaryMetric, experimentMap, onOpenExperiment
export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
row, weeks, onDrillDown, stockMap, top50Ranking, top50Mode, sortConfig,
renderGrowth, customerFilters, vendorStockMap, velocityMap, buyBoxLostMap,
primaryMetric
}) => {
const ranks: { rank: number; label: string; theme: 'amber' | 'blue' | 'indigo' }[] = [];
const asin = row.asin.trim().toUpperCase();
@@ -126,12 +75,6 @@ export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
internalStock={stockMap?.get(row.sku?.replace(/(DE|EN)$/i, ''))}
/>
<BuyBoxWarningBadge asin={asin} buyBoxLostMap={buyBoxLostMap} />
{experimentMap && (
<ExperimentBadge
experiments={experimentMap.get(asin) || []}
onClick={onOpenExperiment}
/>
)}
</div>
<span className="text-[9px] text-fuchsia-400/80 font-bold uppercase tracking-widest">{row.line}</span>
</div>
@@ -158,33 +101,8 @@ export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
const cvr = gv > 0 ? (val / gv) * 100 : 0;
const prevCvr = prevGv > 0 ? (prevVal / prevGv) * 100 : 0;
const experimentsInWeek = experimentMap?.get(asin)?.filter(exp => isWeekInExperiment(week, exp)) || [];
const hasActiveExperiment = experimentsInWeek.length > 0;
let highlightClass = '';
let experimentDelta: number | null = null;
let baselineValue: number | null = null;
if (hasActiveExperiment) {
const primaryExp = experimentsInWeek[0];
if (primaryExp.type === 'pricing') highlightClass = 'bg-amber-500/10 border-t border-amber-500/20';
else if (primaryExp.type === 'advertising') highlightClass = 'bg-rose-500/10 border-t border-rose-500/20';
else if (primaryExp.type === 'content') highlightClass = 'bg-emerald-500/10 border-t border-emerald-500/20';
else highlightClass = 'bg-indigo-500/10 border-t border-indigo-500/20';
const baseline = getBaselineMetrics(row, weeks, primaryExp.start_date);
if (baseline) {
const myMetric = primaryMetric === 'units' ? val : revenue;
const baseMetric = primaryMetric === 'units' ? baseline.units : baseline.revenue;
if (baseMetric > 0) {
experimentDelta = ((myMetric - baseMetric) / baseMetric) * 100;
baselineValue = baseMetric;
}
}
}
return (
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''} ${highlightClass}`}>
<td key={week} className={`p-3 py-2 text-center border-r border-white/5 align-middle ${sortConfig?.key === week ? 'bg-white/[0.01]' : ''}`}>
{primaryMetric === 'units' ? (
<MetricDetailTooltip
currentValue={val}
@@ -195,8 +113,6 @@ export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
yoyWeekLabel={`Week ${weekNum} (${parseInt(year) - 1})`}
metricName="Units"
metricColor="text-white"
experimentDelta={experimentDelta}
baselineValue={baselineValue}
>
<div className="flex items-center gap-1">
<span className={`text-sm font-bold ${val > 0 ? (sortConfig?.key === week && sortConfig.metric === 'units' ? 'text-indigo-400' : 'text-white') : 'text-slate-700'}`}>
@@ -216,8 +132,6 @@ export const WeeklyRow: React.FC<WeeklyRowProps> = React.memo(({
metricName="Revenue"
metricColor="text-white"
formatValue={(v) => `${v.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`}
experimentDelta={experimentDelta}
baselineValue={baselineValue}
>
<div className="flex items-center gap-1">
<span className={`text-sm font-bold ${revenue > 0 ? (sortConfig?.key === week && sortConfig.metric === 'revenue' ? 'text-amber-400' : 'text-white') : 'text-slate-700'}`}>
-74
View File
@@ -1,74 +0,0 @@
import React from 'react';
import { formatCurrency, formatPercent, calculateProductMetrics } from './utils';
import { Product } from './types';
import { DollarSign, TrendingUp, AlertTriangle, Activity } from 'lucide-react';
interface KPICardsProps {
products: Product[];
includeCOGS: boolean;
}
export const KPICards: React.FC<KPICardsProps> = ({ products, includeCOGS }) => {
const totals = products.reduce(
(acc, product) => {
const metrics = calculateProductMetrics(product, includeCOGS);
acc.grossSales += product.grossSales;
acc.netMargin += metrics.netMargin;
acc.ppcSpend += product.ppcSpend;
acc.chargebacks += product.chargebacks;
return acc;
},
{ grossSales: 0, netMargin: 0, ppcSpend: 0, chargebacks: 0 }
);
const marginPercent = totals.grossSales > 0 ? totals.netMargin / totals.grossSales : 0;
const tacos = totals.grossSales > 0 ? totals.ppcSpend / totals.grossSales : 0;
const cards = [
{
title: 'Total Sales',
value: formatCurrency(totals.grossSales),
icon: <DollarSign className="w-6 h-6 text-emerald-400" />,
description: 'Gross revenue for the period',
},
{
title: 'Est. Net Margin',
value: formatPercent(marginPercent),
subValue: formatCurrency(totals.netMargin),
icon: <TrendingUp className="w-6 h-6 text-indigo-400" />,
description: includeCOGS ? 'After COGS and expenses' : 'Before COGS',
},
{
title: 'TACOS',
value: formatPercent(tacos),
icon: <Activity className="w-6 h-6 text-purple-400" />,
description: 'Total advertising cost / Sales',
},
{
title: 'Total Chargebacks',
value: formatCurrency(totals.chargebacks),
icon: <AlertTriangle className="w-6 h-6 text-rose-400" />,
description: 'Logistics issues / returns',
},
];
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{cards.map((card, idx) => (
<div key={idx} className="bg-[#13161F] rounded-xl border border-[#1F2433] p-6 flex flex-col">
<div className="flex justify-between items-start mb-4">
<h3 className="text-sm font-medium text-slate-400 uppercase tracking-wider">{card.title}</h3>
<div className="p-2 bg-[#0A0C10] rounded-lg border border-[#1F2433]">{card.icon}</div>
</div>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-bold text-white">{card.value}</span>
{card.subValue && (
<span className="text-sm font-medium text-slate-400">({card.subValue})</span>
)}
</div>
<p className="text-xs text-slate-500 mt-2">{card.description}</p>
</div>
))}
</div>
);
};
-286
View File
@@ -1,286 +0,0 @@
import React, { useState, useMemo } from 'react';
import { Product } from './types';
import { formatCurrency, formatPercent, calculateProductMetrics, cn } from './utils';
import { ArrowUpDown, AlertCircle, ChevronUp, ChevronDown } from 'lucide-react';
import { StockBadge } from '../StockBadge';
import { VendorStockBadge } from '../VendorStockBadge';
import { BuyBoxWarningBadge } from '../BuyBoxWarningBadge';
import { Top50Badge } from '../Top50Badge';
interface MasterTableProps {
products: Product[];
includeCOGS: boolean;
onProductClick: (product: Product) => void;
stockMap?: Map<string, number>;
vendorStockMap?: Map<string, { eu: number; uk: number }>;
top50Mode?: 'eu' | 'uk';
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
}
type SortKey = 'name' | 'grossSales' | 'ppcSpend' | 'deals' | 'promos' | 'chargebacks' | 'netMargin' | 'marginPercent';
type SortOrder = 'asc' | 'desc';
export const MasterTable: React.FC<MasterTableProps> = ({
products,
includeCOGS,
onProductClick,
stockMap,
vendorStockMap,
top50Mode = 'eu',
velocityMap,
buyBoxLostMap,
top50Ranking
}) => {
const [sortKey, setSortKey] = useState<SortKey>('marginPercent');
const [sortOrder, setSortOrder] = useState<SortOrder>('asc');
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortOrder('desc'); // Default to desc for new sort
}
};
const totals = useMemo(() => {
return products.reduce((acc, product) => {
const metrics = calculateProductMetrics(product, includeCOGS);
acc.grossSales += product.grossSales;
acc.ppcSpend += product.ppcSpend;
acc.deals += product.deals;
acc.promos += product.promos;
acc.chargebacks += product.chargebacks;
acc.netMargin += metrics.netMargin;
return acc;
}, { grossSales: 0, ppcSpend: 0, deals: 0, promos: 0, chargebacks: 0, netMargin: 0 });
}, [products, includeCOGS]);
const totalMarginPercent = totals.grossSales > 0 ? totals.netMargin / totals.grossSales : 0;
const totalAcos = totals.grossSales > 0 ? totals.ppcSpend / totals.grossSales : 0;
const sortedProducts = useMemo(() => {
return products.slice().sort((a, b) => {
const metricsA = calculateProductMetrics(a, includeCOGS);
const metricsB = calculateProductMetrics(b, includeCOGS);
let valA: number | string;
let valB: number | string;
switch (sortKey) {
case 'name':
valA = a.name;
valB = b.name;
break;
case 'grossSales':
valA = a.grossSales;
valB = b.grossSales;
break;
case 'ppcSpend':
valA = a.ppcSpend;
valB = b.ppcSpend;
break;
case 'deals':
valA = a.deals;
valB = b.deals;
break;
case 'promos':
valA = a.promos;
valB = b.promos;
break;
case 'chargebacks':
valA = a.chargebacks;
valB = b.chargebacks;
break;
case 'netMargin':
valA = metricsA.netMargin;
valB = metricsB.netMargin;
break;
case 'marginPercent':
valA = metricsA.marginPercent;
valB = metricsB.marginPercent;
break;
default:
valA = 0;
valB = 0;
}
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}, [products, includeCOGS, sortKey, sortOrder]);
const SortIcon = ({ columnKey }: { columnKey: SortKey }) => {
if (sortKey !== columnKey) return <ArrowUpDown className="w-4 h-4 text-slate-600 ml-1 inline-block" />;
return sortOrder === 'asc' ?
<ChevronUp className="w-4 h-4 text-indigo-400 ml-1 inline-block" /> :
<ChevronDown className="w-4 h-4 text-indigo-400 ml-1 inline-block" />;
};
return (
<div className="bg-[#13161F] rounded-xl border border-[#1F2433] overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-[#0A0C10] border-b border-[#1F2433] text-slate-400 font-medium text-xs uppercase tracking-wider">
<tr>
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors" onClick={() => handleSort('name')}>
Product <SortIcon columnKey="name" />
</th>
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('grossSales')}>
SELL OUT <SortIcon columnKey="grossSales" />
</th>
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('ppcSpend')}>
PPC Spend (ACOS) <SortIcon columnKey="ppcSpend" />
</th>
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('deals')}>
Deals <SortIcon columnKey="deals" />
</th>
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('promos')}>
Promos <SortIcon columnKey="promos" />
</th>
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('chargebacks')}>
Op. Chargebacks <SortIcon columnKey="chargebacks" />
</th>
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('netMargin')}>
Est. Margin ($) <SortIcon columnKey="netMargin" />
</th>
<th className="px-6 py-4 cursor-pointer hover:bg-[#1A1E2A] transition-colors text-right" onClick={() => handleSort('marginPercent')}>
Margin (%) <SortIcon columnKey="marginPercent" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-[#1F2433]">
{/* Totals Row */}
<tr className="bg-[#1A1E2A] border-b-2 border-[#2D3348] font-semibold">
<td className="px-6 py-4 text-slate-200 uppercase tracking-wider text-xs">TOTALS</td>
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.grossSales)}</td>
<td className="px-6 py-4 text-right text-slate-200">
{formatCurrency(totals.ppcSpend)}
<div className="text-xs text-amber-400 font-normal mt-0.5">ACOS: {formatPercent(totalAcos)}</div>
</td>
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.deals)}</td>
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.promos)}</td>
<td className="px-6 py-4 text-right text-slate-200">{formatCurrency(totals.chargebacks)}</td>
<td className="px-6 py-4 text-right text-emerald-400">{formatCurrency(totals.netMargin)}</td>
<td className="px-6 py-4 text-right text-slate-200">
<span className={cn(
"inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold",
totalMarginPercent >= 0.20 ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" :
totalMarginPercent >= 0.10 ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" :
"bg-rose-500/10 text-rose-400 border border-rose-500/20"
)}>
{formatPercent(totalMarginPercent)}
</span>
</td>
</tr>
{/* Product Rows */}
{(() => {
const maxSales = products.reduce((max, p) => Math.max(max, p.grossSales), 0);
return sortedProducts.map((product) => {
const metrics = calculateProductMetrics(product, includeCOGS);
// Data bar width calculation (relative to max sales)
const salesBarWidth = maxSales > 0 ? `${(product.grossSales / maxSales) * 100}%` : '0%';
return (
<tr
key={product.id}
className="hover:bg-[#1A1E2A] transition-colors cursor-pointer group"
onClick={() => onProductClick(product)}
>
<td className="px-6 py-4 whitespace-normal min-w-[300px] max-w-[400px]">
<div className="font-medium text-slate-200 group-hover:text-indigo-400 transition-colors line-clamp-2" title={product.name}>{product.name}</div>
<div className="text-xs text-slate-500 font-mono mt-0.5 mb-2">{product.sku} | {product.asin}</div>
<div className="flex items-center gap-2">
{stockMap && (
<StockBadge stock={stockMap.get(product.sku?.replace(/(DE|EN)$/i, ''))} />
)}
{(() => {
const asin = product.asin.trim().toUpperCase();
if (asin && top50Ranking) {
if (top50Mode === 'eu') {
const rank = top50Ranking.eu.get(asin);
if (rank) return <Top50Badge rank={rank} label="EU" theme="indigo" />;
} else {
const rank = top50Ranking.uk.get(asin);
if (rank) return <Top50Badge rank={rank} label="UK" theme="blue" />;
}
}
return null;
})()}
<VendorStockBadge
asin={product.asin}
vendorStockMap={vendorStockMap}
mode={top50Mode}
avgWeeklySales={velocityMap?.get(product.asin.trim().toUpperCase())}
internalStock={stockMap?.get(product.sku?.replace(/(DE|EN)$/i, ''))}
/>
<BuyBoxWarningBadge asin={product.asin} buyBoxLostMap={buyBoxLostMap} />
</div>
</td>
<td className="px-6 py-4 text-right">
<div className="flex flex-col items-end">
<span className="font-medium text-slate-200">{formatCurrency(product.grossSales)}</span>
<div className="w-24 h-1.5 bg-[#1F2433] rounded-full mt-1.5 overflow-hidden flex justify-end">
<div className="h-full bg-indigo-500 rounded-full" style={{ width: salesBarWidth }} />
</div>
</div>
</td>
<td className="px-6 py-4 text-right">
<div className="font-medium text-slate-200">{formatCurrency(product.ppcSpend)}</div>
<div className="text-xs text-slate-500 mt-0.5">ACOS: <span className="text-amber-400">{formatPercent(metrics.acos)}</span></div>
</td>
<td className="px-6 py-4 text-right">
<div className="font-medium text-slate-200">{formatCurrency(product.deals)}</div>
</td>
<td className="px-6 py-4 text-right">
<div className="font-medium text-slate-200">{formatCurrency(product.promos)}</div>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
{metrics.hasChargebackAnomaly && (
<div className="group/tooltip relative" title={`${formatPercent(metrics.chargebackIncrease)} increase vs previous month`}>
<AlertCircle className="w-4 h-4 text-rose-500" />
</div>
)}
<span className={cn(
"font-medium",
metrics.hasChargebackAnomaly ? "text-rose-400" : "text-slate-200"
)}>
{formatCurrency(product.chargebacks)}
</span>
</div>
</td>
<td className="px-6 py-4 text-right">
<span className={cn(
"font-medium",
metrics.netMargin < 0 ? "text-rose-400" : "text-emerald-400"
)}>
{formatCurrency(metrics.netMargin)}
</span>
</td>
<td className="px-6 py-4 text-right">
<div className="flex items-center justify-end gap-2">
<span className={cn(
"inline-flex items-center px-2.5 py-1 rounded-md text-xs font-semibold",
metrics.marginPercent >= 0.20 ? "bg-emerald-500/10 text-emerald-400 border border-emerald-500/20" :
metrics.marginPercent >= 0.10 ? "bg-amber-500/10 text-amber-400 border border-amber-500/20" :
"bg-rose-500/10 text-rose-400 border border-rose-500/20"
)}>
{metrics.marginPercent > 0 ? '▲' : '▼'} {formatPercent(Math.abs(metrics.marginPercent))}
</span>
</div>
</td>
</tr>
);
})})()}
</tbody>
</table>
</div>
</div>
);
};
-349
View File
@@ -1,349 +0,0 @@
import React, { useState, useMemo, useEffect } from 'react';
import * as XLSX from 'xlsx';
import { SalesRecord, AdsRecord } from '../../types';
import { Product } from './types';
import { KPICards } from './KPICards';
import { MasterTable } from './MasterTable';
import { WaterfallModal } from './WaterfallModal';
import { Settings2 } from 'lucide-react';
// ---------------------------------------------------------------------------
// Excel parsing helpers (all files use raw: true so numbers come back as JS numbers)
// ---------------------------------------------------------------------------
function parseNumericCell(val: unknown): number {
if (typeof val === 'number') return isNaN(val) ? 0 : val;
if (typeof val === 'string') {
const clean = val.replace(/[€$£\s]/g, '').trim();
if (!clean) return 0;
// EU format: "1.234,56" — comma is decimal, dot is thousands
if (clean.includes(',') && clean.includes('.') && clean.indexOf(',') > clean.indexOf('.')) {
return parseFloat(clean.replace(/\./g, '').replace(',', '.')) || 0;
}
// EU format: "263,83" — only comma, treat as decimal
if (clean.includes(',') && !clean.includes('.')) {
return parseFloat(clean.replace(',', '.')) || 0;
}
return parseFloat(clean.replace(/,/g, '')) || 0;
}
return 0;
}
// Deals file: ASIN = col A (idx 0), total deal cost = col C (idx 2)
function parseDealsExcel(buffer: ArrayBuffer): Map<string, number> {
const wb = XLSX.read(buffer, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
const map = new Map<string, number>();
for (let i = 1; i < rows.length; i++) {
const row = rows[i] as unknown[];
const asin = row[0];
const cost = row[2];
if (asin && typeof asin === 'string' && asin.trim()) {
const key = asin.trim().toUpperCase();
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
}
}
return map;
}
// Promos file: ASIN = col E (idx 4), promo cost = col K (idx 10)
function parsePromosExcel(buffer: ArrayBuffer): Map<string, number> {
const wb = XLSX.read(buffer, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
const map = new Map<string, number>();
for (let i = 1; i < rows.length; i++) {
const row = rows[i] as unknown[];
const asin = row[4];
const cost = row[10];
if (asin && typeof asin === 'string' && asin.trim()) {
const key = asin.trim().toUpperCase();
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
}
}
return map;
}
// Chargebacks file: ASIN = col AN (idx 39), chargeback cost = col B (idx 1)
function parseChargebacksExcel(buffer: ArrayBuffer): Map<string, number> {
const wb = XLSX.read(buffer, { type: 'array' });
const ws = wb.Sheets[wb.SheetNames[0]];
const rows = XLSX.utils.sheet_to_json<unknown[]>(ws, { header: 1, raw: true });
const map = new Map<string, number>();
for (let i = 1; i < rows.length; i++) {
const row = rows[i] as unknown[];
const asin = row[39];
const cost = row[1];
if (asin && typeof asin === 'string' && asin.trim()) {
const key = asin.trim().toUpperCase();
map.set(key, (map.get(key) || 0) + parseNumericCell(cost));
}
}
return map;
}
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
interface MktDataViewProps {
rawData: SalesRecord[];
adsData: AdsRecord[];
stockMap?: Map<string, number>;
vendorStockMap?: Map<string, { eu: number; uk: number }>;
top50Mode?: 'eu' | 'uk';
velocityMap?: Map<string, number>;
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
top50Ranking?: { eu: Map<string, number>; uk: Map<string, number> };
}
export default function MktDataView({
rawData,
adsData,
stockMap,
vendorStockMap,
top50Mode = 'eu',
velocityMap,
buyBoxLostMap,
top50Ranking
}: MktDataViewProps) {
const [includeCOGS, setIncludeCOGS] = useState(true);
const [selectedProduct, setSelectedProduct] = useState<Product | null>(null);
const [mktLoading, setMktLoading] = useState(true);
const [dealsMap, setDealsMap] = useState<Map<string, number>>(new Map());
const [promosMap, setPromosMap] = useState<Map<string, number>>(new Map());
const [chargebacksMap, setChargebacksMap] = useState<Map<string, number>>(new Map());
// Fetch the 3 new marketing data files once on mount
useEffect(() => {
let cancelled = false;
const fetchAll = async () => {
setMktLoading(true);
try {
const [dealsRes, promosRes, chargesRes] = await Promise.all([
fetch('/api/fetch-mkt-data?file=deals'),
fetch('/api/fetch-mkt-data?file=promos'),
fetch('/api/fetch-mkt-data?file=chargebacks'),
]);
if (cancelled) return;
if (dealsRes.ok) {
const buf = await dealsRes.arrayBuffer();
if (!cancelled) setDealsMap(parseDealsExcel(buf));
} else {
console.warn('[MktDataView] fetch-mkt-data?file=deals failed:', dealsRes.status);
}
if (promosRes.ok) {
const buf = await promosRes.arrayBuffer();
if (!cancelled) setPromosMap(parsePromosExcel(buf));
} else {
console.warn('[MktDataView] fetch-mkt-data?file=promos failed:', promosRes.status);
}
if (chargesRes.ok) {
const buf = await chargesRes.arrayBuffer();
if (!cancelled) setChargebacksMap(parseChargebacksExcel(buf));
} else {
console.warn('[MktDataView] fetch-mkt-data?file=chargebacks failed:', chargesRes.status);
}
} catch (e) {
console.error('[MktDataView] Error fetching marketing data:', e);
} finally {
if (!cancelled) setMktLoading(false);
}
};
fetchAll();
return () => { cancelled = true; };
}, []);
// Build Product[] from real data sources
const allProducts = useMemo((): Product[] => {
if (rawData.length === 0) return [];
// ASIN → best metadata (longest title wins)
const metaMap = new Map<string, { sku: string; title: string; line: string }>();
rawData.forEach(r => {
const asin = r.asin.trim().toUpperCase();
const existing = metaMap.get(asin);
if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) {
metaMap.set(asin, { sku: r.sku || '', title: r.title || '', line: r.line || 'Other' });
}
});
// ASIN → global sell-out and units
const sellOutMap = new Map<string, number>();
const unitsMap = new Map<string, number>();
rawData.forEach(r => {
const asin = r.asin.trim().toUpperCase();
sellOutMap.set(asin, (sellOutMap.get(asin) || 0) + r.sellOut);
unitsMap.set(asin, (unitsMap.get(asin) || 0) + r.units);
});
// ASIN → global ad spend
const adsSpendMap = new Map<string, number>();
let totalAdsPassed = 0;
adsData.forEach(r => {
const asin = r.asin.trim().toUpperCase();
adsSpendMap.set(asin, (adsSpendMap.get(asin) || 0) + r.cost);
totalAdsPassed += r.cost;
});
// Deals/Promos/Chargebacks
const has2025 = rawData.some(r => r.year === 2025) || adsData.some(r => r.year === 2025);
const getDeals = (asin: string) => has2025 ? (dealsMap.get(asin) || 0) : 0;
const getPromos = (asin: string) => has2025 ? (promosMap.get(asin) || 0) : 0;
const getChargebacks = (asin: string) => has2025 ? (chargebacksMap.get(asin) || 0) : 0;
// 1. Start with all sales-based ASINs
const products: Product[] = Array.from(metaMap.entries())
.map(([asin, meta]): Product => {
const spend = adsSpendMap.get(asin) || 0;
adsSpendMap.delete(asin); // Mark as processed
return {
id: asin,
asin,
sku: meta.sku,
name: meta.title || asin,
image: '',
category: meta.line,
brand: '',
grossSales: sellOutMap.get(asin) || 0,
unitsSold: unitsMap.get(asin) || 0,
ppcSpend: spend,
deals: getDeals(asin),
promos: getPromos(asin),
chargebacks: getChargebacks(asin),
chargebacksPrevMonth: 0,
cogs: 0,
};
});
// 2. Add remaining ad spend (including "" ASIN and ASINs without sales)
adsSpendMap.forEach((spend, asin) => {
if (spend === 0) return;
products.push({
id: asin || '__unassigned__',
asin: asin || '—',
sku: '',
name: asin ? asin : 'Unassigned Ad Spend (incl. Sponsored Brands)',
image: '',
category: 'Other',
brand: '',
grossSales: 0,
unitsSold: 0,
ppcSpend: spend,
deals: asin ? getDeals(asin) : 0,
promos: asin ? getPromos(asin) : 0,
chargebacks: asin ? getChargebacks(asin) : 0,
chargebacksPrevMonth: 0,
cogs: 0,
});
});
// Debug check
const finalTotal = products.reduce((sum, p) => sum + p.ppcSpend, 0);
if (Math.abs(finalTotal - totalAdsPassed) > 0.01) {
console.warn(`[MktDataView] PPC Spend Mismatch: Total Ads=${totalAdsPassed.toFixed(2)}, Table Sum=${finalTotal.toFixed(2)}`);
}
return products;
}, [rawData, adsData, dealsMap, promosMap, chargebacksMap]);
const filteredProducts = allProducts;
// ---------------------------------------------------------------------------
// Loading skeleton — shown while MKT files are being fetched or rawData is empty
// ---------------------------------------------------------------------------
if (rawData.length === 0 || mktLoading) {
return (
<div className="font-sans text-slate-200 px-4 md:px-6 pb-24">
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-white">Profitability Dashboard</h2>
<p className="text-slate-400">Overview of product performance and margins.</p>
</div>
</div>
{/* Skeleton KPI cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-8">
{[...Array(4)].map((_, i) => (
<div key={i} className="bg-[#13161F] rounded-xl border border-[#1F2433] p-6 h-32 animate-pulse" />
))}
</div>
{/* Skeleton table */}
<div className="bg-[#13161F] rounded-xl border border-[#1F2433] overflow-hidden">
{[...Array(8)].map((_, i) => (
<div key={i} className="px-6 py-4 border-b border-[#1F2433] animate-pulse flex gap-4">
<div className="h-4 bg-[#1F2433] rounded w-1/3" />
<div className="h-4 bg-[#1F2433] rounded w-1/6 ml-auto" />
<div className="h-4 bg-[#1F2433] rounded w-1/6" />
<div className="h-4 bg-[#1F2433] rounded w-1/6" />
</div>
))}
</div>
</div>
);
}
return (
<div className="font-sans text-slate-200 px-4 md:px-6 pb-24">
{/* Header Controls */}
<div className="flex items-center justify-between mb-8">
<div>
<h2 className="text-2xl font-bold text-white">Profitability Dashboard</h2>
<p className="text-slate-400">Overview of product performance and margins.</p>
</div>
<div className="flex items-center gap-4">
{/* COGS Toggle */}
<div className="flex items-center gap-2 bg-[#0A0C10] px-3 py-1.5 rounded-lg border border-[#1F2433]">
<Settings2 className="w-4 h-4 text-slate-400" />
<label className="flex items-center gap-2 cursor-pointer">
<div className="relative">
<input
type="checkbox"
className="sr-only"
checked={includeCOGS}
onChange={() => setIncludeCOGS(!includeCOGS)}
/>
<div className={`block w-10 h-6 rounded-full transition-colors ${includeCOGS ? 'bg-indigo-600' : 'bg-[#1F2433]'}`}></div>
<div className={`absolute left-1 top-1 bg-white w-4 h-4 rounded-full transition-transform ${includeCOGS ? 'transform translate-x-4' : ''}`}></div>
</div>
<span className="text-sm font-medium text-slate-300">Include COGS</span>
</label>
</div>
</div>
</div>
<KPICards products={filteredProducts} includeCOGS={includeCOGS} />
<div className="mb-8">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">Product Performance</h3>
<p className="text-sm text-slate-400">Click on a product to view the waterfall breakdown.</p>
</div>
<MasterTable
products={filteredProducts}
includeCOGS={includeCOGS}
onProductClick={setSelectedProduct}
stockMap={stockMap}
vendorStockMap={vendorStockMap}
top50Mode={top50Mode}
velocityMap={velocityMap}
buyBoxLostMap={buyBoxLostMap}
top50Ranking={top50Ranking}
/>
</div>
{/* Modal */}
<WaterfallModal
product={selectedProduct}
includeCOGS={includeCOGS}
onClose={() => setSelectedProduct(null)}
/>
</div>
);
}
-165
View File
@@ -1,165 +0,0 @@
import React, { useMemo } from 'react';
import { Product } from './types';
import { calculateProductMetrics, formatCurrency, formatPercent } from './utils';
import { X } from 'lucide-react';
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Cell, ReferenceLine } from 'recharts';
interface WaterfallModalProps {
product: Product | null;
includeCOGS: boolean;
onClose: () => void;
}
export const WaterfallModal: React.FC<WaterfallModalProps> = ({ product, includeCOGS, onClose }) => {
const data = useMemo(() => {
if (!product) return [];
const metrics = calculateProductMetrics(product, includeCOGS);
let currentTotal = product.grossSales;
const steps = [
{ name: 'Gross Sales', value: product.grossSales, isTotal: true, color: '#6366f1' }, // Indigo
{ name: 'PPC', value: -product.ppcSpend, isTotal: false, color: '#ef4444' }, // Rose
{ name: 'Deals', value: -product.deals, isTotal: false, color: '#f97316' }, // Orange
{ name: 'Promos', value: -product.promos, isTotal: false, color: '#f59e0b' }, // Amber
{ name: 'Chargebacks', value: -product.chargebacks, isTotal: false, color: '#eab308' }, // Yellow
];
if (includeCOGS) {
steps.push({ name: 'COGS', value: -product.cogs, isTotal: false, color: '#64748b' }); // Slate
}
steps.push({ name: 'Net Margin', value: metrics.netMargin, isTotal: true, color: metrics.netMargin >= 0 ? '#10b981' : '#ef4444' });
const chartData = steps.map(step => {
if (step.isTotal) {
return {
name: step.name,
start: 0,
end: step.value,
val: step.value,
color: step.color,
isTotal: true
};
} else {
const start = currentTotal;
currentTotal += step.value; // value is negative
return {
name: step.name,
start: currentTotal, // The bottom of the visible bar
end: start, // The top of the visible bar
val: step.value,
color: step.color,
isTotal: false
};
}
});
// Transform for stacked bar chart: [bottomTransparent, visibleBar]
return chartData.map(d => ({
name: d.name,
transparent: d.start,
visible: Math.abs(d.end - d.start),
val: d.val,
color: d.color,
isTotal: d.isTotal
}));
}, [product, includeCOGS]);
if (!product) return null;
const metrics = calculateProductMetrics(product, includeCOGS);
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-[#13161F] rounded-2xl shadow-2xl border border-[#1F2433] w-full max-w-4xl max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-[#13161F] border-b border-[#1F2433] px-6 py-4 flex items-center justify-between z-10">
<div className="flex items-center gap-4">
<div>
<h2 className="text-xl font-bold text-white">{product.name}</h2>
<p className="text-sm text-slate-400 font-mono mt-0.5">{product.sku} | {product.asin}</p>
</div>
</div>
<button onClick={onClose} className="p-2 text-slate-400 hover:text-white hover:bg-[#1F2433] rounded-full transition-colors">
<X className="w-6 h-6" />
</button>
</div>
<div className="p-6">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div className="bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
<p className="text-xs text-slate-400 font-medium mb-1 uppercase tracking-wider">Gross Sales</p>
<p className="text-lg font-bold text-white">{formatCurrency(product.grossSales)}</p>
</div>
<div className="bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
<p className="text-xs text-slate-400 font-medium mb-1 uppercase tracking-wider">Net Margin</p>
<p className={`text-lg font-bold ${metrics.netMargin >= 0 ? 'text-emerald-400' : 'text-rose-400'}`}>{formatCurrency(metrics.netMargin)}</p>
</div>
<div className="bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
<p className="text-xs text-slate-400 font-medium mb-1 uppercase tracking-wider">Margin %</p>
<p className={`text-lg font-bold ${metrics.marginPercent >= 0.2 ? 'text-emerald-400' : metrics.marginPercent >= 0.1 ? 'text-amber-400' : 'text-rose-400'}`}>
{formatPercent(metrics.marginPercent)}
</p>
</div>
<div className="bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
<p className="text-xs text-slate-400 font-medium mb-1 uppercase tracking-wider">ACOS</p>
<p className="text-lg font-bold text-amber-400">{formatPercent(metrics.acos)}</p>
</div>
</div>
<div className="mb-6">
<h3 className="text-lg font-semibold text-white">Profitability Analysis (Waterfall)</h3>
<p className="text-sm text-slate-400">Breakdown of deductions from gross sales to net margin.</p>
</div>
<div className="h-[400px] w-full bg-[#0A0C10] p-4 rounded-xl border border-[#1F2433]">
<ResponsiveContainer width="100%" height="100%">
<BarChart data={data} margin={{ top: 20, right: 20, bottom: 40, left: 20 }}>
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#1F2433" />
<XAxis
dataKey="name"
tick={{ fontSize: 12, fill: '#8B949E' }}
axisLine={{ stroke: '#1F2433' }}
tickLine={false}
angle={-45}
textAnchor="end"
height={60}
/>
<YAxis
tickFormatter={(val) => `$${val / 1000}k`}
tick={{ fontSize: 12, fill: '#8B949E' }}
axisLine={false}
tickLine={false}
/>
<Tooltip
cursor={{ fill: '#13161F' }}
content={({ active, payload }) => {
if (active && payload && payload.length) {
const data = payload[0].payload;
return (
<div className="bg-[#13161F] p-3 border border-[#1F2433] shadow-xl rounded-lg text-sm">
<p className="font-medium text-white mb-1">{data.name}</p>
<p className={`font-bold ${data.val < 0 ? 'text-rose-400' : 'text-emerald-400'}`}>
{data.val > 0 && !data.isTotal ? '+' : ''}{formatCurrency(data.val)}
</p>
</div>
);
}
return null;
}}
/>
<ReferenceLine y={0} stroke="#475569" />
<Bar dataKey="transparent" stackId="a" fill="transparent" />
<Bar dataKey="visible" stackId="a" radius={[4, 4, 4, 4]}>
{data.map((entry, index) => (
<Cell key={`cell-${index}`} fill={entry.color} />
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</div>
</div>
</div>
</div>
);
};
-140
View File
@@ -1,140 +0,0 @@
import { Product } from './types';
export const mockProducts: Product[] = [
{
id: '1',
asin: 'B08F7N8P1Q',
sku: 'SKU-WIDGET-01',
name: 'Premium Widget Pro Max',
image: 'https://picsum.photos/seed/widget1/100/100',
category: 'Electronics',
brand: 'TechCorp',
grossSales: 45000,
unitsSold: 1500,
ppcSpend: 4500,
deals: 1200,
promos: 800,
chargebacks: 300,
chargebacksPrevMonth: 250,
cogs: 15000,
},
{
id: '2',
asin: 'B09G8M7P2R',
sku: 'SKU-GADGET-02',
name: 'Smart Gadget Mini',
image: 'https://picsum.photos/seed/gadget2/100/100',
category: 'Electronics',
brand: 'TechCorp',
grossSales: 12000,
unitsSold: 800,
ppcSpend: 3000,
deals: 500,
promos: 200,
chargebacks: 800,
chargebacksPrevMonth: 400, // Anomaly! > 15% increase
cogs: 6000,
},
{
id: '3',
asin: 'B07H6L5P3S',
sku: 'SKU-HOME-03',
name: 'Ergonomic Office Chair',
image: 'https://picsum.photos/seed/chair3/100/100',
category: 'Home & Office',
brand: 'HomePlus',
grossSales: 85000,
unitsSold: 425,
ppcSpend: 8500,
deals: 2000,
promos: 1500,
chargebacks: 1200,
chargebacksPrevMonth: 1100,
cogs: 35000,
},
{
id: '4',
asin: 'B06J5K4P4T',
sku: 'SKU-KITCHEN-04',
name: 'Stainless Steel Knife Set',
image: 'https://picsum.photos/seed/knife4/100/100',
category: 'Kitchen',
brand: 'ChefMaster',
grossSales: 28000,
unitsSold: 700,
ppcSpend: 4200,
deals: 800,
promos: 600,
chargebacks: 150,
chargebacksPrevMonth: 160,
cogs: 9000,
},
{
id: '5',
asin: 'B05K4J3P5U',
sku: 'SKU-FITNESS-05',
name: 'Yoga Mat Extra Thick',
image: 'https://picsum.photos/seed/yoga5/100/100',
category: 'Fitness',
brand: 'FitLife',
grossSales: 15000,
unitsSold: 600,
ppcSpend: 3500,
deals: 1000,
promos: 500,
chargebacks: 400,
chargebacksPrevMonth: 380,
cogs: 5000,
},
{
id: '6',
asin: 'B04L3H2P6V',
sku: 'SKU-BEAUTY-06',
name: 'Organic Face Serum',
image: 'https://picsum.photos/seed/serum6/100/100',
category: 'Beauty',
brand: 'NatureGlow',
grossSales: 32000,
unitsSold: 1280,
ppcSpend: 2800,
deals: 500,
promos: 300,
chargebacks: 100,
chargebacksPrevMonth: 90,
cogs: 8000,
},
{
id: '7',
asin: 'B03M2G1P7W',
sku: 'SKU-TOY-07',
name: 'Educational Building Blocks',
image: 'https://picsum.photos/seed/toy7/100/100',
category: 'Toys',
brand: 'KidGenius',
grossSales: 9500,
unitsSold: 380,
ppcSpend: 2500,
deals: 400,
promos: 200,
chargebacks: 50,
chargebacksPrevMonth: 45,
cogs: 4000,
},
{
id: '8',
asin: 'B02N1F0P8X',
sku: 'SKU-PET-08',
name: 'Automatic Pet Feeder',
image: 'https://picsum.photos/seed/pet8/100/100',
category: 'Pet Supplies',
brand: 'PetCare',
grossSales: 42000,
unitsSold: 840,
ppcSpend: 6000,
deals: 1500,
promos: 1000,
chargebacks: 600,
chargebacksPrevMonth: 550,
cogs: 18000,
}
];
-17
View File
@@ -1,17 +0,0 @@
export interface Product {
id: string;
asin: string;
sku: string;
name: string;
image: string;
category: string;
brand: string;
grossSales: number;
unitsSold: number;
ppcSpend: number;
deals: number;
promos: number;
chargebacks: number;
chargebacksPrevMonth: number;
cogs: number;
}
-48
View File
@@ -1,48 +0,0 @@
import { Product } from './types';
import { clsx, type ClassValue } from 'clsx';
import { twMerge } from 'tailwind-merge';
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
export const formatCurrency = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
minimumFractionDigits: 0,
maximumFractionDigits: 0,
}).format(value);
};
export const formatPercent = (value: number) => {
return new Intl.NumberFormat('en-US', {
style: 'percent',
minimumFractionDigits: 1,
maximumFractionDigits: 1,
}).format(value);
};
export const calculateProductMetrics = (product: Product, includeCOGS: boolean) => {
const incentives = product.deals + product.promos;
const cogsDeduction = includeCOGS ? product.cogs : 0;
const totalDeductions = product.ppcSpend + incentives + product.chargebacks + cogsDeduction;
const netMargin = product.grossSales - totalDeductions;
const marginPercent = product.grossSales > 0 ? netMargin / product.grossSales : 0;
const acos = product.grossSales > 0 ? product.ppcSpend / product.grossSales : 0;
const chargebackIncrease = product.chargebacksPrevMonth > 0
? (product.chargebacks - product.chargebacksPrevMonth) / product.chargebacksPrevMonth
: 0;
const hasChargebackAnomaly = chargebackIncrease > 0.15;
return {
incentives,
totalDeductions,
netMargin,
marginPercent,
acos,
chargebackIncrease,
hasChargebackAnomaly
};
};
-100
View File
@@ -1,100 +0,0 @@
import { createClient } from '@supabase/supabase-js';
import { readFileSync } from 'fs';
import { processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor';
import { computeDiD } from './services/experimentAnalysis';
import { CombinedKPIs } from './types';
// Load .env.local if it exists
import { config } from 'dotenv';
config({ path: '.env.local' });
async function run() {
const supabase = createClient(
process.env.SUPABASE_URL || '',
process.env.SUPABASE_SERVICE_KEY || ''
);
// Get the INKEE DE experiment
const { data: experiments, error } = await supabase
.from('experiments')
.select('*')
.ilike('name', '%INKEE%')
.eq('marketplace', 'DE');
if (error) {
console.error('Supabase error:', error);
return;
}
if (!experiments || experiments.length === 0) {
console.log('No INKEE DE experiments found. Listing all experiments...');
const { data: all } = await supabase.from('experiments').select('name, marketplace, start_date, end_date, asins').limit(20);
console.log(JSON.stringify(all, null, 2));
return;
}
for (const exp of experiments) {
console.log(`\n=== Experiment: "${exp.name}" ===`);
console.log(` Marketplace: ${exp.marketplace}`);
console.log(` Start: ${exp.start_date} → End: ${exp.end_date}`);
console.log(` Baseline: ${exp.baseline_start_date}${exp.baseline_end_date}`);
console.log(` ASINs (${exp.asins?.length || 0}):`, exp.asins);
console.log(` Control ASINs (${exp.control_asins?.length || 0}):`, exp.control_asins);
}
// Now load the Ads data and check all ASINs
console.log('\n=== Loading Ads data ===');
const buf = readFileSync('/tmp/Ads-Weekly.xlsx').buffer;
const adsData = await processAdsExcel(buf);
const merged = mergeSalesAndAdsData([], adsData) as CombinedKPIs[];
const inkeeExp = experiments[0];
const allAsins = inkeeExp.asins?.map((a: string) => a.trim().toUpperCase()) || [];
console.log(`\n=== Checking ${allAsins.length} ASINs in Ads data (weeks 7-8 of 2026) ===`);
let totalCostW7W8 = 0;
let totalAdRevW7W8 = 0;
let missingFromAds: string[] = [];
for (const asin of allAsins) {
const adRecords = merged.filter(r =>
r.asin === asin &&
r.year === 2026 &&
[7, 8].includes(r.week) &&
(r.marketplace || r.customer || '').toLowerCase().includes('de')
);
if (adRecords.length === 0) {
missingFromAds.push(asin);
} else {
const cost = adRecords.reduce((s, r) => s + (r.cost || 0), 0);
const adRev = adRecords.reduce((s, r) => s + (r.salesAds || 0), 0);
const clicks = adRecords.reduce((s, r) => s + (r.clicks || 0), 0);
if (cost > 0 || adRev > 0 || clicks > 0) {
console.log(` ${asin}: W7+W8 cost=${cost.toFixed(2)} adRev=${adRev.toFixed(2)} clicks=${clicks}`);
totalCostW7W8 += cost;
totalAdRevW7W8 += adRev;
} else {
console.log(` ${asin}: W7+W8 cost=0 adRev=0 clicks=0 (zero activity)`);
}
}
}
console.log(`\nMissing from Ads file entirely (${missingFromAds.length}):`, missingFromAds);
console.log(`\nTotal W7+W8 2026 cost: €${totalCostW7W8.toFixed(2)}`);
console.log(`Total W7+W8 2026 adRev: €${totalAdRevW7W8.toFixed(2)}`);
if (totalCostW7W8 > 0) {
console.log(`Implied ACOS: ${(totalCostW7W8 / (totalAdRevW7W8 || 1) * 100).toFixed(1)}%`);
}
// Run actual DiD
const did = computeDiD(inkeeExp, merged);
console.log('\n=== DiD Result ===');
console.log('ACOS:', did.metrics['acos']);
console.log('ROAS:', did.metrics['roas']);
console.log('Revenue:', did.metrics['revenue']);
console.log('Units:', did.metrics['units']);
}
run().catch(console.error);
-243
View File
@@ -1,243 +0,0 @@
import { createClient } from '@supabase/supabase-js';
interface Experiment {
id: string;
name: string;
start_date: string;
end_date?: string;
marketplace: string;
asins: string[];
baseline_start_date?: string;
baseline_end_date?: string;
}
// Configura Supabase con credenciales hardcodeadas para debug
const supabase = createClient(
'https://qjioywarwdbxmdihyrti.supabase.co',
'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InFqaW95d2Fyd2RieG1kaWh5cnRpIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImlhdCI6MTc3MTUxMDU0NCwiZXhwIjoyMDg3MDg2NTQ0fQ.oXxdNBDvMSyfMRaf57oCb8cUS2Bqm73CeyM19v3GJPI'
);
async function debugBodynessExperiment() {
console.log('=== DEBUG BODYNESS ES EXPERIMENT ===\n');
// 1. Obtener el experimento
const { data: expData, error: expError } = await supabase
.from('experiments')
.select('*')
.ilike('name', '%bodyness%')
.single();
if (expError || !expData) {
console.log('❌ No se encontró el experimento');
console.log('Error:', expError);
return;
}
const experiment: Experiment = expData;
console.log('📋 Experimento:', experiment.name);
console.log('📅 Start:', experiment.start_date);
console.log('📅 End:', experiment.end_date);
console.log('🌍 Marketplace:', experiment.marketplace);
console.log('📦 ASINs:', experiment.asins);
console.log('');
// 2. Obtener datos de ventas (sin filtro de año primero) - verificar total
const { count, error: countError } = await supabase
.from('vendor_daily_data')
.select('*', { count: 'exact', head: true });
console.log(`📊 Total registros en vendor_daily_data: ${count || 'unknown'}`);
console.log('');
// Buscar específicamente los ASINs del experimento
console.log('🔍 Buscando ASINs del experimento en la DB...');
const { data: asinData, error: asinError } = await supabase
.from('vendor_daily_data')
.select('*')
.in('asin', experiment.asins);
if (asinError) {
console.log('Error buscando ASINs:', asinError.message);
} else {
console.log(`✅ Registros encontrados para los ASINs del experimento: ${asinData?.length || 0}`);
if (asinData && asinData.length > 0) {
const asinsEncontrados = [...new Set(asinData.map(r => r.asin))];
console.log('ASINs encontrados:', asinsEncontrados);
const marketsEncontrados = [...new Set(asinData.map(r => r.market))];
console.log('Markets encontrados:', marketsEncontrados);
}
}
console.log('');
// Obtener datos filtrados por los ASINs del experimento (usamos asinData que ya tiene los datos)
const salesData = asinData || [];
console.log(`📊 Total registros para ASINs del experimento: ${salesData.length}`);
console.log('');
// Ver muestra de datos
console.log('📋 Muestra de datos (primeros 3 registros - todos los campos):');
salesData.slice(0, 3).forEach((r: any, i) => {
console.log(` ${i+1}.`, JSON.stringify(r, null, 2));
});
console.log('');
// Ver rango de fechas
const dates = salesData.map(r => r.date).filter(Boolean).sort();
if (dates.length > 0) {
console.log(`📅 Rango de fechas: ${dates[0]}${dates[dates.length - 1]}`);
}
console.log('');
// 4. Ver marketplace en los datos
const marketplaces = [...new Set(salesData.map(r => r.market || 'N/A'))];
console.log('🌍 Marketplaces en los datos:', marketplaces);
console.log('');
// 5. Filtrar por marketplace del experimento (ES)
const mktFilter = (experiment.marketplace || '').toLowerCase();
console.log(`Filtrando por marketplace: "${experiment.marketplace}"`);
const filteredByMkt = salesData.filter(r => {
const mkt = (r.market || '').toLowerCase();
return !mktFilter || mktFilter === 'all' || mkt.includes(mktFilter) || mktFilter.includes(mkt);
});
console.log(`✅ Registros después de filtrar por marketplace: ${filteredByMkt.length}`);
console.log('');
// 6. Agrupar por semana ISO (usando el campo date)
const weeksMap = new Map<string, { units: number; records: number; dates: string[] }>();
filteredByMkt.forEach((r: any) => {
const dateStr = r.date;
if (!dateStr) return;
const date = new Date(dateStr);
const iso = getISOWeek(date);
const key = `${iso.year}-W${String(iso.week).padStart(2, '0')}`;
const existing = weeksMap.get(key) || { units: 0, records: 0, dates: [] as string[] };
existing.units += (r.units || r.unitsTotal || 0);
existing.records++;
if (!existing.dates.includes(dateStr)) existing.dates.push(dateStr);
weeksMap.set(key, existing);
});
console.log('📅 Unidades por semana (TODOS los datos):');
const sortedWeeks = Array.from(weeksMap.entries()).sort();
sortedWeeks.forEach(([week, data]) => {
console.log(` ${week}: ${data.units} unidades (${data.records} registros) - fechas: ${data.dates.sort().join(', ')}`);
});
console.log('');
// 7. Ver semanas dentro del período del experimento (Jan 22 - Feb 4, 2026)
const startDate = new Date(experiment.start_date!);
const endDate = new Date(experiment.end_date!);
console.log('📅 Período del experimento:', startDate.toISOString().split('T')[0], '→', endDate.toISOString().split('T')[0]);
// Calcular semanas ISO que toca el experimento
const startISO = getISOWeek(startDate);
const endISO = getISOWeek(endDate);
console.log(`📅 Semanas ISO: ${startISO.year}-W${startISO.week}${endISO.year}-W${endISO.week}`);
const afterWeeks: string[] = [];
let currentYear = startISO.year;
let currentWeek = startISO.week;
while (true) {
const weekKey = `${currentYear}-W${String(currentWeek).padStart(2, '0')}`;
afterWeeks.push(weekKey);
if (currentYear === endISO.year && currentWeek === endISO.week) {
break;
}
currentWeek++;
const weeksInYear = getWeeksInYear(currentYear);
if (currentWeek > weeksInYear) {
currentWeek = 1;
currentYear++;
}
}
console.log(`📅 Semanas incluidas en AFTER: ${afterWeeks.join(', ')}`);
console.log('');
// 8. Calcular unidades en esas semanas
let afterUnits = 0;
let afterWeekCount = 0;
console.log('📊 Unidades en semanas AFTER:');
sortedWeeks.forEach(([week, data]) => {
if (afterWeeks.includes(week)) {
afterUnits += data.units;
afterWeekCount++;
console.log(`${week}: ${data.units} unidades`);
}
});
console.log('');
console.log(`📊 Total unidades en AFTER: ${afterUnits}`);
console.log(`📊 Número de semanas con datos: ${afterWeekCount}`);
console.log(`📊 Media calculada: ${afterUnits} / ${afterWeekCount} = ${(afterUnits / afterWeekCount).toFixed(2)}`);
console.log('');
// 9. Ver baseline (3 semanas antes)
const baselineEndTs = getISOWeekMonday(startISO.year, startISO.week);
const baselineStartTs = baselineEndTs - (afterWeeks.length * 7 * 86400000);
console.log('📅 Baseline período:', new Date(baselineStartTs).toISOString().split('T')[0], '→', new Date(baselineEndTs).toISOString().split('T')[0]);
// Calcular semanas del baseline
const baselineWeeks: string[] = [];
let baselineDate = new Date(baselineStartTs);
while (baselineDate.getTime() < baselineEndTs) {
const iso = getISOWeek(baselineDate);
const weekKey = `${iso.year}-W${String(iso.week).padStart(2, '0')}`;
if (!baselineWeeks.includes(weekKey)) {
baselineWeeks.push(weekKey);
}
baselineDate.setDate(baselineDate.getDate() + 7);
}
console.log(`📅 Semanas del BASELINE: ${baselineWeeks.join(', ')}`);
let beforeUnits = 0;
let beforeWeekCount = 0;
console.log('📊 Unidades en semanas BASELINE:');
sortedWeeks.forEach(([week, data]) => {
if (baselineWeeks.includes(week)) {
beforeUnits += data.units;
beforeWeekCount++;
console.log(`${week}: ${data.units} unidades`);
}
});
console.log('');
console.log(`📊 Total unidades en BASELINE: ${beforeUnits}`);
console.log(`📊 Número de semanas con datos: ${beforeWeekCount}`);
console.log(`📊 Media calculada: ${beforeUnits} / ${beforeWeekCount} = ${(beforeUnits / beforeWeekCount).toFixed(2)}`);
}
// Funciones helper ISO week
function getISOWeek(date: Date): { year: number; week: number } {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNum = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return { year: d.getUTCFullYear(), week: weekNum };
}
function getWeeksInYear(year: number): number {
const dec28 = new Date(Date.UTC(year, 11, 28));
const iso = getISOWeek(dec28);
return iso.week;
}
function getISOWeekMonday(year: number, week: number): number {
const jan4 = new Date(Date.UTC(year, 0, 4));
const dayOfWeek = jan4.getUTCDay() || 7;
const week1Monday = new Date(Date.UTC(year, 0, 4 - (dayOfWeek - 1)));
return week1Monday.getTime() + (week - 1) * 7 * 86400000;
}
debugBodynessExperiment().catch(console.error);
-70
View File
@@ -1,70 +0,0 @@
import XLSX from 'xlsx';
import { readFileSync } from 'fs';
import { processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor';
import { computeDiD } from './services/experimentAnalysis';
import { getExperimentAsins } from './services/experiments';
import { Experiment, CombinedKPIs } from './types';
// Simulate the getWeekStartSunday function from experimentAnalysis.ts
function getWeekStartSunday(year: number, week: number): number {
const jan1 = new Date(year, 0, 1);
const day = jan1.getDay();
const startYear = new Date(year, 0, 1 - day);
return startYear.getTime() + (week - 1) * 7 * 86400000;
}
async function run() {
const buf = readFileSync('/tmp/Ads-Weekly.xlsx').buffer;
const adsData = await processAdsExcel(buf);
const merged = mergeSalesAndAdsData([], adsData);
// Show the timestamps for INKEE DE weeks
const inkeeDE = merged.filter(m =>
m.asin.startsWith('B0CQ') &&
(m.marketplace || m.customer || '').includes('DE')
);
console.log('=== INKEE DE TIMESTAMP ANALYSIS ===');
for (const r of inkeeDE.slice(0, 5)) {
const weekStartTs = getWeekStartSunday(r.year, r.week);
const weekStartDate = new Date(weekStartTs).toISOString().split('T')[0];
console.log(`ASIN:${r.asin} ${r.year}-W${r.week} weekStart:${weekStartDate} cost:${r.cost.toFixed(2)}`);
}
console.log('\n=== WEEK DATE RANGES ===');
for (let week = 1; week <= 8; week++) {
const ts2025 = getWeekStartSunday(2025, week);
const ts2026 = getWeekStartSunday(2026, week);
const d2025 = new Date(ts2025).toISOString().split('T')[0];
const d2026 = new Date(ts2026).toISOString().split('T')[0];
console.log(`Week ${week}: 2025 → ${d2025} | 2026 → ${d2026}`);
}
// Now simulate an experiment that spans the existing data range
// The experiment "INKEE DE" - let's test with dates in Jan 2026 (W1-W4)
const testExperiment: Experiment = {
id: 'test',
name: 'INKEE DE Test',
type: 'advertising',
status: 'active',
asins: ['B0CQ247T2V', 'B0CQTLZRCV'],
control_asins: [],
marketplace: 'DE',
start_date: '2026-01-05', // Start of week 2, 2026
end_date: '2026-02-22', // End of week 8, 2026
baseline_start_date: '2025-01-06', // Week 2, 2025
baseline_end_date: '2025-02-23', // Week 8, 2025
primary_metric: 'acos',
notes: '',
owner: ''
};
console.log('\n=== DiD SIMULATION ===');
const did = computeDiD(testExperiment, merged as CombinedKPIs[]);
console.log('ACOS:', did.metrics['acos']);
console.log('ROAS:', did.metrics['roas']);
console.log('Units:', did.metrics['units']);
}
run().catch(console.error);
-541
View File
@@ -1,541 +0,0 @@
import {
Experiment,
CombinedKPIs,
DifferenceInDifferencesResult,
DiDMetricResult,
ExperimentVerdict,
} from '../types';
import { getExperimentAsins } from './experiments';
// ============ Math Helpers ============
function erf(x: number): number {
const a1 = 0.254829592, a2 = -0.284496736, a3 = 1.421413741;
const a4 = -1.453152027, a5 = 1.061405429, p = 0.3275911;
const sign = x < 0 ? -1 : 1;
const abs = Math.abs(x);
const t = 1.0 / (1.0 + p * abs);
const y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * Math.exp(-abs * abs);
return sign * y;
}
function normalCDF(x: number): number {
return 0.5 * (1 + erf(x / Math.sqrt(2)));
}
// ============ Weekly Metric Aggregation ============
interface WeeklyMetrics {
week: string; // "2025-W05"
timestamp: number;
units: number;
revenue: number;
adRevenue: number;
sessions: number; // glanceViews
cost: number;
clicks: number;
impressions: number;
detail_bsr: number;
bsrCount: number;
}
interface ComputedWeeklyMetrics extends WeeklyMetrics {
cvr: number;
ctr: number;
roas: number;
}
function getWeekStartSunday(year: number, week: number): number {
const jan1 = new Date(year, 0, 1);
const day = jan1.getDay(); // 0 = Sunday, 1 = Monday...
const startYear = new Date(year, 0, 1 - day); // Sunday of the week containing Jan 1
return startYear.getTime() + (week - 1) * 7 * 86400000;
}
/**
* Get ISO week number for a given date
* ISO weeks start on Monday, week 1 contains the first Thursday of the year
*/
function getISOWeek(date: Date): { year: number; week: number } {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7; // Convert Sunday (0) to 7
d.setUTCDate(d.getUTCDate() + 4 - dayNum); // Set to nearest Thursday
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNum = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return { year: d.getUTCFullYear(), week: weekNum };
}
/**
* Get the Monday of an ISO week
*/
function getISOWeekMonday(year: number, week: number): number {
const jan4 = new Date(Date.UTC(year, 0, 4)); // Jan 4 is always in week 1
const dayOfWeek = jan4.getUTCDay() || 7; // Get day of week (1-7, Monday=1)
const week1Monday = new Date(Date.UTC(year, 0, 4 - (dayOfWeek - 1)));
return week1Monday.getTime() + (week - 1) * 7 * 86400000;
}
/**
* Calculate the number of distinct ISO weeks between two dates
* and return the expanded date range (first Monday to last Sunday)
*/
function calculateISOWeekRange(startDate: Date, endDate: Date): {
numWeeks: number;
expandedStartTs: number;
expandedEndTs: number;
weekKeys: string[];
} {
const startISO = getISOWeek(startDate);
const endISO = getISOWeek(endDate);
const weekKeys: string[] = [];
let currentYear = startISO.year;
let currentWeek = startISO.week;
// Collect all week keys between start and end
while (true) {
const weekKey = `${currentYear}-W${String(currentWeek).padStart(2, '0')}`;
weekKeys.push(weekKey);
if (currentYear === endISO.year && currentWeek === endISO.week) {
break;
}
// Move to next week
currentWeek++;
const weeksInYear = getWeeksInYear(currentYear);
if (currentWeek > weeksInYear) {
currentWeek = 1;
currentYear++;
}
}
// Get expanded range: from Monday of first week to Sunday of last week
const expandedStartTs = getISOWeekMonday(startISO.year, startISO.week);
const expandedEndTs = getISOWeekMonday(endISO.year, endISO.week) + 7 * 86400000;
return {
numWeeks: weekKeys.length,
expandedStartTs,
expandedEndTs,
weekKeys,
};
}
/**
* Get number of weeks in an ISO year (52 or 53)
*/
function getWeeksInYear(year: number): number {
const dec28 = new Date(Date.UTC(year, 11, 28)); // Dec 28 is always in last week
const iso = getISOWeek(dec28);
return iso.week;
}
function aggregateWeeklyMetrics(
asinSet: Set<string>,
salesData: CombinedKPIs[],
marketplace: string
): ComputedWeeklyMetrics[] {
const weeklyMap = new Map<string, WeeklyMetrics>();
for (const r of salesData) {
const asin = (r.asin || '').trim().toUpperCase();
if (!asinSet.has(asin)) continue;
const mkt = (r.marketplace || (r as any).customer || '').toLowerCase();
if (marketplace && marketplace !== 'All' && !mkt.includes(marketplace.toLowerCase())) continue;
const weekNum = r.week || 1;
const year = r.year || new Date().getFullYear();
const key = `${year}-W${String(weekNum).padStart(2, '0')}`;
if (!weeklyMap.has(key)) {
// Use ISO week start (Monday) instead of Sunday for consistency
weeklyMap.set(key, {
week: key,
timestamp: getISOWeekMonday(year, weekNum),
units: 0,
revenue: 0,
adRevenue: 0,
sessions: 0,
cost: 0,
clicks: 0,
impressions: 0,
detail_bsr: 0,
bsrCount: 0,
});
}
const w = weeklyMap.get(key)!;
w.units += r.unitsTotal || (r as any).units || 0;
w.revenue += r.salesTotal || (r as any).sellOut || 0;
w.adRevenue += r.salesAds || 0;
w.sessions += r.glanceViews || 0;
w.cost += Number(r.cost) || 0;
w.clicks += r.clicks || 0;
w.impressions += r.impressions || 0;
if (r.detailLevelBSR != null) {
w.detail_bsr += r.detailLevelBSR;
w.bsrCount += 1;
}
}
// Debug ad map
console.log(`[aggregateWeeklyMetrics] ASIN match for ${marketplace}:`, asinSet);
const debugArr = Array.from(weeklyMap.values());
const hasAds = debugArr.some(w => w.cost > 0 || w.adRevenue > 0);
if (!hasAds) {
console.log(`[aggregateWeeklyMetrics WARNING] 0 ad data grouped for mkt ${marketplace}!`, debugArr);
} else {
console.log(`[aggregateWeeklyMetrics SUCCESS] Found ad usage:`, debugArr.filter(w => w.cost > 0));
}
return debugArr
.sort((a, b) => a.timestamp - b.timestamp)
.map(w => ({
...w,
cvr: w.sessions > 0 ? (w.units / w.sessions) * 100 : 0,
ctr: w.impressions > 0 ? (w.clicks / w.impressions) * 100 : 0,
roas: w.cost > 0 ? w.adRevenue / w.cost : 0,
detail_bsr: w.bsrCount > 0 ? w.detail_bsr / w.bsrCount : 0,
}));
}
function getMetricValue(w: ComputedWeeklyMetrics, metric: string): number {
switch (metric) {
case 'units': return w.units;
case 'sessions': return w.sessions;
case 'cvr': return w.cvr;
case 'ctr': return w.ctr;
case 'roas': return w.roas;
case 'revenue': return w.revenue;
case 'acos': return w.cost > 0 && w.adRevenue > 0 ? (w.cost / w.adRevenue) * 100 : 0;
case 'detail_bsr': return w.detail_bsr;
default: return w.units;
}
}
// ============ DiD Computation ============
function parseLocalDate(dateStr?: string): Date {
if (!dateStr) return new Date();
const [y, m, d] = dateStr.split('T')[0].split('-');
return new Date(Number(y), Number(m) - 1, Number(d));
}
function splitPeriods(
data: ComputedWeeklyMetrics[],
startTs: number,
endTs: number,
beforeStartTs: number,
beforeEndTs: number
): { before: ComputedWeeklyMetrics[]; after: ComputedWeeklyMetrics[] } {
const before: ComputedWeeklyMetrics[] = [];
const after: ComputedWeeklyMetrics[] = [];
for (const w of data) {
const weekStartTs = w.timestamp;
const weekEndTs = w.timestamp + 6 * 86400000 + 86399999;
const overlapsAfter = weekEndTs >= startTs && weekStartTs < endTs;
const overlapsBefore = weekEndTs >= beforeStartTs && weekStartTs < beforeEndTs;
if (overlapsAfter) {
after.push(w);
} else if (overlapsBefore) {
before.push(w);
}
}
return { before, after };
}
function avgMetric(data: ComputedWeeklyMetrics[], metric: string, durationWeeks: number): number {
if (data.length === 0) return 0;
// For rates and ratios, we must sum the raw absolute components across all included weeks
// and THEN calculate the ratio, otherwise averaging percentages yields mathematically incorrect results.
if (metric === 'cvr') {
const totalUnits = data.reduce((s, w) => s + w.units, 0);
const totalSessions = data.reduce((s, w) => s + w.sessions, 0);
return totalSessions > 0 ? (totalUnits / totalSessions) * 100 : 0;
}
if (metric === 'ctr') {
const totalClicks = data.reduce((s, w) => s + w.clicks, 0);
const totalImpressions = data.reduce((s, w) => s + w.impressions, 0);
return totalImpressions > 0 ? (totalClicks / totalImpressions) * 100 : 0;
}
if (metric === 'roas') {
const totalAdRevenue = data.reduce((s, w) => s + w.adRevenue, 0);
const totalCost = data.reduce((s, w) => s + w.cost, 0);
return totalCost > 0 ? totalAdRevenue / totalCost : 0;
}
if (metric === 'acos') {
const totalAdRevenue = data.reduce((s, w) => s + w.adRevenue, 0);
const totalCost = data.reduce((s, w) => s + w.cost, 0);
return totalAdRevenue > 0 && totalCost > 0 ? (totalCost / totalAdRevenue) * 100 : 0;
}
if (metric === 'detail_bsr') {
// For detail_bsr, we want the average of the non-zero weeks
const validWeeks = data.filter(w => w.detail_bsr > 0);
if (validWeeks.length === 0) return 0;
const sum = validWeeks.reduce((s, w) => s + w.detail_bsr, 0);
return sum / validWeeks.length;
}
// For absolute quantities (units, revenue, sessions), we sum them and divide by the number of weeks
// Use actual data.length instead of theoretical durationWeeks for accuracy
const sum = data.reduce((s, w) => s + getMetricValue(w, metric), 0);
return sum / data.length;
}
const METRICS = ['units', 'sessions', 'cvr', 'ctr', 'roas', 'revenue', 'acos', 'detail_bsr'];
const LOWER_IS_BETTER = new Set(['acos', 'detail_bsr']);
function computeMetricDiD(
treatmentBefore: ComputedWeeklyMetrics[],
treatmentAfter: ComputedWeeklyMetrics[],
controlBefore: ComputedWeeklyMetrics[],
controlAfter: ComputedWeeklyMetrics[],
metric: string,
hasControlGroup: boolean,
treatmentDurationWeeks: number,
baselineDurationWeeks: number
): DiDMetricResult {
const tBefore = avgMetric(treatmentBefore, metric, baselineDurationWeeks);
const tAfter = avgMetric(treatmentAfter, metric, treatmentDurationWeeks);
const cBefore = hasControlGroup ? avgMetric(controlBefore, metric, baselineDurationWeeks) : 0;
const cAfter = hasControlGroup ? avgMetric(controlAfter, metric, treatmentDurationWeeks) : 0;
let didEstimate: number;
if (hasControlGroup) {
didEstimate = (tAfter - tBefore) - (cAfter - cBefore);
} else {
didEstimate = tAfter - tBefore;
}
// For ACOS, lower is better — invert the estimate
if (LOWER_IS_BETTER.has(metric)) {
didEstimate = -didEstimate;
}
const liftPercent = tBefore !== 0 ? (didEstimate / Math.abs(tBefore)) * 100 : 0;
// Bayesian posterior probability
const weeklyDiffs: number[] = [];
const minLen = Math.min(treatmentAfter.length, hasControlGroup ? controlAfter.length : treatmentAfter.length);
for (let i = 0; i < minLen; i++) {
const tVal = getMetricValue(treatmentAfter[i], metric);
let diff: number;
if (hasControlGroup && controlAfter[i]) {
const cVal = getMetricValue(controlAfter[i], metric);
diff = (tVal - tBefore) - (cVal - cBefore);
} else {
diff = tVal - tBefore;
}
if (LOWER_IS_BETTER.has(metric)) diff = -diff;
weeklyDiffs.push(diff);
}
let posteriorProb = 0.5;
if (weeklyDiffs.length >= 3) {
const mean = weeklyDiffs.reduce((s, v) => s + v, 0) / weeklyDiffs.length;
const variance = weeklyDiffs.reduce((s, v) => s + (v - mean) ** 2, 0) / (weeklyDiffs.length - 1);
const se = Math.sqrt(variance / weeklyDiffs.length);
if (se > 0) {
posteriorProb = normalCDF(mean / se);
} else {
posteriorProb = mean > 0 ? 1 : mean < 0 ? 0 : 0.5;
}
}
return {
treatment_before: Math.round(tBefore * 100) / 100,
treatment_after: Math.round(tAfter * 100) / 100,
control_before: Math.round(cBefore * 100) / 100,
control_after: Math.round(cAfter * 100) / 100,
did_estimate: Math.round(didEstimate * 100) / 100,
lift_percent: Math.round(liftPercent * 10) / 10,
posterior_prob_positive: Math.round(posteriorProb * 1000) / 1000,
};
}
export function computeDiD(
experiment: Experiment,
salesData: CombinedKPIs[]
): DifferenceInDifferencesResult {
const startDate = parseLocalDate(experiment.start_date);
const endDate = experiment.end_date ? parseLocalDate(experiment.end_date) : new Date(new Date().setHours(0, 0, 0, 0));
// Calculate ISO week range: expands to full weeks (Monday to Sunday)
const isoRange = calculateISOWeekRange(startDate, endDate);
const startTs = isoRange.expandedStartTs;
const endTs = isoRange.expandedEndTs;
const treatmentDurationWeeks = isoRange.numWeeks;
let beforeStartTs: number;
let beforeEndTs: number;
if (experiment.baseline_start_date && experiment.baseline_end_date) {
// Use custom baseline dates - also expand to full ISO weeks
const baselineStart = parseLocalDate(experiment.baseline_start_date);
const baselineEnd = parseLocalDate(experiment.baseline_end_date);
const baselineIsoRange = calculateISOWeekRange(baselineStart, baselineEnd);
beforeStartTs = baselineIsoRange.expandedStartTs;
beforeEndTs = baselineIsoRange.expandedEndTs;
} else {
// Take the same number of complete weeks immediately before the experiment
beforeEndTs = startTs; // Baseline ends right when experiment starts (Monday)
beforeStartTs = beforeEndTs - (treatmentDurationWeeks * 7 * 86400000);
}
const treatmentAsinSet = getExperimentAsins(experiment.asins || [], salesData);
const treatmentWeekly = aggregateWeeklyMetrics(treatmentAsinSet, salesData, experiment.marketplace);
const hasControlGroup = (experiment.control_asins || []).length > 0;
let controlWeekly: ComputedWeeklyMetrics[] = [];
if (hasControlGroup) {
const controlAsinSet = getExperimentAsins(experiment.control_asins, salesData);
controlWeekly = aggregateWeeklyMetrics(controlAsinSet, salesData, experiment.marketplace);
}
const tSplit = splitPeriods(treatmentWeekly, startTs, endTs, beforeStartTs, beforeEndTs);
const cSplit = hasControlGroup
? splitPeriods(controlWeekly, startTs, endTs, beforeStartTs, beforeEndTs)
: { before: [] as ComputedWeeklyMetrics[], after: [] as ComputedWeeklyMetrics[] };
const baselineDurationWeeks = Math.max(1, cSplit.before.length || tSplit.before.length);
// Debug: log data flow for ACOS diagnosis
const afterTotalCost = tSplit.after.reduce((s, w) => s + w.cost, 0);
const afterTotalAdRev = tSplit.after.reduce((s, w) => s + w.adRevenue, 0);
const beforeTotalCost = tSplit.before.reduce((s, w) => s + w.cost, 0);
const beforeTotalAdRev = tSplit.before.reduce((s, w) => s + w.adRevenue, 0);
console.log(`[computeDiD DEBUG] Experiment: ${experiment.name} | Mkt: ${experiment.marketplace}`);
console.log(` Treatment ASINs: ${treatmentAsinSet.size} | Weekly buckets: ${treatmentWeekly.length}`);
console.log(` Period: ${new Date(startTs).toISOString().split('T')[0]}${new Date(endTs).toISOString().split('T')[0]}`);
console.log(` Baseline: ${new Date(beforeStartTs).toISOString().split('T')[0]}${new Date(beforeEndTs).toISOString().split('T')[0]}`);
console.log(` After split: ${tSplit.after.length} weeks [cost=${afterTotalCost.toFixed(2)}, adRev=${afterTotalAdRev.toFixed(2)}]`);
console.log(` Before split: ${tSplit.before.length} weeks [cost=${beforeTotalCost.toFixed(2)}, adRev=${beforeTotalAdRev.toFixed(2)}]`);
if (tSplit.after.length > 0) {
console.log(` After weeks: ${tSplit.after.map(w => w.week).join(', ')}`);
}
if (tSplit.before.length > 0) {
console.log(` Before weeks: ${tSplit.before.map(w => w.week).join(', ')}`);
}
const metrics: Record<string, DiDMetricResult> = {};
for (const metric of METRICS) {
metrics[metric] = computeMetricDiD(
tSplit.before, tSplit.after,
cSplit.before, cSplit.after,
metric, hasControlGroup,
treatmentDurationWeeks, baselineDurationWeeks
);
}
return {
metrics,
computed_at: new Date().toISOString(),
};
}
// ============ Verdict ============
export function computeVerdict(
didResult: DifferenceInDifferencesResult,
primaryMetric: string
): { verdict: ExperimentVerdict; probability: number } {
const result = didResult.metrics[primaryMetric];
if (!result) return { verdict: 'inconclusive', probability: 0.5 };
const prob = result.posterior_prob_positive;
if (prob > 0.50) return { verdict: 'winner', probability: prob };
return { verdict: 'loser', probability: prob };
}
// ============ Counterfactual Time Series ============
export interface TrendDataPoint {
week: string;
timestamp: number;
actual: number;
counterfactual: number;
}
export function buildCounterfactualSeries(
experiment: Experiment,
salesData: CombinedKPIs[],
metric: string
): TrendDataPoint[] {
const startDate = parseLocalDate(experiment.start_date);
const endDate = experiment.end_date ? parseLocalDate(experiment.end_date) : new Date(new Date().setHours(0, 0, 0, 0));
const startTs = startDate.getTime();
const endTs = endDate.getTime() + 86400000; // Add 24h
let beforeStartTs: number;
let beforeEndTs: number;
if (experiment.baseline_start_date && experiment.baseline_end_date) {
beforeStartTs = parseLocalDate(experiment.baseline_start_date).getTime();
beforeEndTs = parseLocalDate(experiment.baseline_end_date).getTime() + 86400000;
} else {
const durationMs = endTs - startTs;
beforeEndTs = startTs;
beforeStartTs = beforeEndTs - durationMs;
}
const treatmentAsinSet = getExperimentAsins(experiment.asins || [], salesData);
const treatmentWeekly = aggregateWeeklyMetrics(treatmentAsinSet, salesData, experiment.marketplace);
const hasControlGroup = (experiment.control_asins || []).length > 0;
if (!hasControlGroup) {
const { before: beforeData } = splitPeriods(treatmentWeekly, startTs, endTs, beforeStartTs, beforeEndTs);
const baselineDurationWeeks = Math.max(1, Math.round((beforeEndTs - beforeStartTs) / (7 * 86400000)));
const preAvg = avgMetric(beforeData, metric, baselineDurationWeeks);
return treatmentWeekly.map(w => ({
week: w.week,
timestamp: w.timestamp,
actual: Math.round(getMetricValue(w, metric) * 100) / 100,
counterfactual: Math.round(preAvg * 100) / 100,
}));
}
// Calculate variances for Bayesian update
const baselineDurationWeeks = Math.max(1, Math.round((beforeEndTs - beforeStartTs) / (7 * 86400000)));
const treatmentDurationWeeks = Math.max(1, Math.round((endTs - startTs) / (7 * 86400000)));
const controlAsinSet = getExperimentAsins(experiment.control_asins, salesData);
const controlWeekly = aggregateWeeklyMetrics(controlAsinSet, salesData, experiment.marketplace);
const { before: tBeforeData } = splitPeriods(treatmentWeekly, startTs, endTs, beforeStartTs, beforeEndTs);
const { before: cBeforeData } = splitPeriods(controlWeekly, startTs, endTs, beforeStartTs, beforeEndTs);
const tPreAvg = avgMetric(tBeforeData, metric, baselineDurationWeeks);
const cPreAvg = avgMetric(cBeforeData, metric, baselineDurationWeeks);
// Build a map of control weekly values
const controlMap = new Map<string, number>();
for (const w of controlWeekly) {
controlMap.set(w.week, getMetricValue(w, metric));
}
return treatmentWeekly.map(w => {
const actual = getMetricValue(w, metric);
const controlVal = controlMap.get(w.week) ?? cPreAvg;
// Counterfactual: treatment pre-avg + (control current - control pre-avg)
const counterfactual = tPreAvg + (controlVal - cPreAvg);
return {
week: w.week,
timestamp: w.timestamp,
actual: Math.round(actual * 100) / 100,
counterfactual: Math.round(counterfactual * 100) / 100,
};
});
}
-299
View File
@@ -1,299 +0,0 @@
import {
Experiment,
ExperimentCreateInput,
ExperimentListItem,
ActiveExperiment,
ExperimentType,
ExperimentStatus,
ExperimentVerdict,
CombinedKPIs,
} from '../types';
const API_BASE = '/api/experiments';
// ============ ASIN Resolution ============
export const getExperimentAsins = (experimentAsins: string[], salesData: CombinedKPIs[]): Set<string> => {
const explicitAsins = new Set<string>();
const lines = new Set<string>();
(experimentAsins || []).forEach(a => {
const val = (a || '').trim().toUpperCase();
if (val.startsWith('LINE:')) {
lines.add(val.substring(5).trim());
} else if (val) {
explicitAsins.add(val);
}
});
const asinSet = new Set<string>(explicitAsins);
if (lines.size > 0 && salesData) {
salesData.forEach(r => {
const line = (r.line || '').trim().toUpperCase();
if (line && lines.has(line)) {
if (r.asin) asinSet.add(r.asin.toUpperCase());
}
});
}
return asinSet;
};
// ============ CRUD Operations ============
export const createExperiment = async (input: ExperimentCreateInput): Promise<Experiment> => {
const response = await fetch(API_BASE, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to create experiment');
}
return response.json();
};
export const updateExperiment = async (
id: string,
updates: Partial<Experiment>
): Promise<Experiment> => {
const response = await fetch(`${API_BASE}?id=${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
if (!response.ok) {
let errText = await response.text();
try {
const errJson = JSON.parse(errText);
errText = errJson.error || errText;
} catch (e) { }
throw new Error(errText);
}
return response.json();
};
export const deleteExperiment = async (id: string): Promise<void> => {
const response = await fetch(`${API_BASE}?id=${id}`, {
method: 'DELETE',
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to delete experiment');
}
};
export const getExperiment = async (id: string): Promise<Experiment | null> => {
const response = await fetch(`${API_BASE}?id=${id}`);
if (!response.ok) return null;
return response.json();
};
export const listExperiments = async (
filters?: {
status?: ExperimentStatus[];
type?: ExperimentType[];
marketplace?: string[];
asin?: string;
dateFrom?: string;
dateTo?: string;
}
): Promise<ExperimentListItem[]> => {
const params = new URLSearchParams();
if (filters?.status?.length) {
filters.status.forEach(s => params.append('status', s));
}
if (filters?.type?.length) {
filters.type.forEach(t => params.append('type', t));
}
if (filters?.marketplace?.length) {
filters.marketplace.forEach(m => params.append('marketplace', m));
}
if (filters?.asin) {
params.append('asin', filters.asin);
}
if (filters?.dateFrom) {
params.append('dateFrom', filters.dateFrom);
}
if (filters?.dateTo) {
params.append('dateTo', filters.dateTo);
}
const response = await fetch(`${API_BASE}?${params.toString()}`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to list experiments');
}
const data = await response.json();
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,
asins: exp.asins || [],
control_asin_count: exp.control_asins?.length || 0,
marketplace: exp.marketplace,
start_date: exp.start_date,
end_date: exp.end_date,
progress_percent: Math.round(progressPercent),
primary_metric: exp.primary_metric,
verdict: exp.verdict,
verdict_probability: exp.verdict_probability,
actual_lift_percent: exp.actual_lift_percent,
owner: exp.owner,
};
});
};
export const getActiveExperiments = async (
salesData: CombinedKPIs[]
): Promise<Map<string, ActiveExperiment[]>> => {
const response = await fetch(`${API_BASE}?status=active`);
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || 'Failed to fetch active experiments');
}
const data = await response.json();
const today = new Date().toISOString().split('T')[0];
const activeExperiments = data.filter((exp: any) =>
exp.status === 'active' &&
exp.start_date <= today &&
(!exp.end_date || exp.end_date >= today)
);
const map = new Map<string, ActiveExperiment[]>();
for (const exp of activeExperiments) {
const expAsins = getExperimentAsins(exp.asins || [], salesData);
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;
const activeExp: ActiveExperiment = {
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,
};
expAsins.forEach(asin => {
const asinList = map.get(asin) || [];
asinList.push({ ...activeExp, asin });
map.set(asin, asinList);
});
}
return map;
};
// ============ Display Helpers ============
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';
case 'seo': return 'bg-cyan-500/20 text-cyan-400 border-cyan-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 '🏷️';
case 'seo': return '🔍';
default: return '🧪';
}
};
export const getVerdictColor = (verdict?: ExperimentVerdict): string => {
switch (verdict) {
case 'winner': return 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30';
case 'loser': return 'bg-red-500/15 text-red-400 border-red-500/30';
case 'inconclusive': return 'bg-slate-500/15 text-slate-400 border-slate-500/30';
default: return 'bg-slate-800/50 text-slate-500 border-slate-700';
}
};
export const getVerdictLabel = (verdict?: ExperimentVerdict): string => {
switch (verdict) {
case 'winner': return 'Winner';
case 'loser': return 'Unsuccessful';
case 'inconclusive': return 'Unsuccessful';
default: return 'Pending';
}
};
export const METRIC_LABELS: Record<string, string> = {
units: 'Units Sold',
sessions: 'Sessions',
cvr: 'Conversion Rate',
ctr: 'Click-Through Rate',
roas: 'ROAS',
revenue: 'Revenue',
acos: 'ACOS',
detail_bsr: 'Detail Level BSR',
};
export const formatMetricValue = (value: number, metric: string): string => {
switch (metric) {
case 'cvr':
case 'ctr':
case 'acos':
return `${value.toFixed(1)}%`;
case 'roas':
return `${value.toFixed(2)}x`;
case 'revenue':
return `${value.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
case 'detail_bsr':
return `#${value.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`;
default:
return value.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 2 });
}
};
-57
View File
@@ -1,57 +0,0 @@
import fs from 'fs';
import { processCSV } from './services/dataProcessor';
import { processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor';
import { computeDiD } from './services/experimentAnalysis';
async function runTest() {
console.log("Loading sales data...");
const csvStr = fs.readFileSync('public/Craze analytix 2026.csv', 'utf-8');
const sales = await processCSV(csvStr);
console.log("Loading ads data...");
const excelBuffer = fs.readFileSync('public/ads.xlsx').buffer;
const ads = await processAdsExcel(excelBuffer);
console.log("Sales rows:", sales.length);
console.log("Ads rows:", ads.length);
// Check INKEE DE explicitly
const inkeeSales = sales.filter(s => s.asin.includes('B0CQ'))
console.log("INKEE Sales rows:", inkeeSales.length);
const inkeeAds = ads.filter(a => a.asin.includes('B0CQ'))
console.log("INKEE Ad rows:", inkeeAds.length);
console.log("Merging...");
const merged = mergeSalesAndAdsData(sales, ads);
const mergedInkee = merged.filter(m => m.asin.includes('B0CQ') && m.year === 2024);
const totalCost = mergedInkee.reduce((sum, r) => sum + (r.cost || 0), 0);
const totalAdSales = mergedInkee.reduce((sum, r) => sum + (r.salesAds || 0), 0);
console.log("Merged INKEE total cost 2024:", totalCost);
console.log("Merged INKEE total ad sales 2024:", totalAdSales);
const experiment = {
id: "INKEE_DE",
name: "INKEE DE Test",
status: "active",
type: "advertising",
asins: ["B0CQXNBGBQ"], // Assuming this is INKEE DE
control_asins: [],
marketplace: "Amazon DE", // Matching the UI
start_date: "2024-03-01",
end_date: "2024-04-15",
baseline_start_date: "2024-01-01",
baseline_end_date: "2024-02-28",
primary_metric: "acos"
} as any;
console.log("Running DiD...");
const did = computeDiD(experiment, merged);
console.log("DiD Output:", JSON.stringify(did.metrics, null, 2));
}
runTest().catch(console.error);
-94
View File
@@ -1,94 +0,0 @@
// Verificar fechas ISO para el experimento BODYNESS
function getISOWeek(date: Date): { year: number; week: number } {
const d = new Date(Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()));
const dayNum = d.getUTCDay() || 7;
d.setUTCDate(d.getUTCDate() + 4 - dayNum);
const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
const weekNum = Math.ceil((((d.getTime() - yearStart.getTime()) / 86400000) + 1) / 7);
return { year: d.getUTCFullYear(), week: weekNum };
}
function getISOWeekMonday(year: number, week: number): number {
const jan4 = new Date(Date.UTC(year, 0, 4));
const dayOfWeek = jan4.getUTCDay() || 7;
const week1Monday = new Date(Date.UTC(year, 0, 4 - (dayOfWeek - 1)));
return week1Monday.getTime() + (week - 1) * 7 * 86400000;
}
function getWeeksInYear(year: number): number {
const dec28 = new Date(Date.UTC(year, 11, 28));
const iso = getISOWeek(dec28);
return iso.week;
}
function calculateISOWeekRange(startDate: Date, endDate: Date): {
numWeeks: number;
expandedStartTs: number;
expandedEndTs: number;
weekKeys: string[];
} {
const startISO = getISOWeek(startDate);
const endISO = getISOWeek(endDate);
const weekKeys: string[] = [];
let currentYear = startISO.year;
let currentWeek = startISO.week;
while (true) {
const weekKey = `${currentYear}-W${String(currentWeek).padStart(2, '0')}`;
weekKeys.push(weekKey);
if (currentYear === endISO.year && currentWeek === endISO.week) {
break;
}
currentWeek++;
const weeksInYear = getWeeksInYear(currentYear);
if (currentWeek > weeksInYear) {
currentWeek = 1;
currentYear++;
}
}
const expandedStartTs = getISOWeekMonday(startISO.year, startISO.week);
const expandedEndTs = getISOWeekMonday(endISO.year, endISO.week) + 7 * 86400000;
return {
numWeeks: weekKeys.length,
expandedStartTs,
expandedEndTs,
weekKeys,
};
}
// Experimento: 22 enero - 4 febrero 2026
const startDate = new Date('2026-01-22');
const endDate = new Date('2026-02-04');
console.log('=== Experimento BODYNESS ES ===');
console.log('Start:', startDate.toISOString().split('T')[0], '(day', startDate.getDay(), ')');
console.log('End:', endDate.toISOString().split('T')[0], '(day', endDate.getDay(), ')');
console.log('');
const startISO = getISOWeek(startDate);
const endISO = getISOWeek(endDate);
console.log('Start ISO:', `W${startISO.week}-${startISO.year}`);
console.log('End ISO:', `W${endISO.week}-${endISO.year}`);
console.log('');
const isoRange = calculateISOWeekRange(startDate, endDate);
console.log('ISO Week Range:');
console.log(' numWeeks:', isoRange.numWeeks);
console.log(' weekKeys:', isoRange.weekKeys);
console.log(' expandedStart:', new Date(isoRange.expandedStartTs).toISOString().split('T')[0]);
console.log(' expandedEnd:', new Date(isoRange.expandedEndTs).toISOString().split('T')[0]);
console.log('');
// Verificar cada semana
isoRange.weekKeys.forEach(week => {
const [year, weekNum] = week.split('-W');
const monday = getISOWeekMonday(Number(year), Number(weekNum));
const sunday = monday + 6 * 86400000 + 86399999;
console.log(` ${week}: ${new Date(monday).toISOString().split('T')[0]} (Mon) → ${new Date(sunday).toISOString().split('T')[0]} (Sun)`);
});
-40
View File
@@ -1,40 +0,0 @@
import { computeDiD } from './services/experimentAnalysis';
import { CombinedKPIs, Experiment } from './types';
const salesData: CombinedKPIs[] = [
{
id: '1', marketplace: 'Amazon DE', customer: 'Amazon DE', month: 'Jan-24', week: 1, year: 2024,
asin: 'B0C123', title: 'Test', line: 'Test', sku: 'T-1',
salesTotal: 1000, unitsTotal: 100, salesAds: 500, unitsAds: 50, cost: 100,
clicks: 100, impressions: 1000, conversions: 5, salesOrganic: 500, unitsOrganic: 50,
paidSalesShare: 50, organicSalesShare: 50, acos: 20, tacos: 10, roas: 5, ctr: 10, cpc: 1, cvrUnits: 5, glanceViews: 1000
},
{
id: '2', marketplace: 'Amazon DE', customer: 'Amazon DE', month: 'Feb-24', week: 5, year: 2024,
asin: 'B0C123', title: 'Test', line: 'Test', sku: 'T-1',
salesTotal: 2000, unitsTotal: 200, salesAds: 2000, unitsAds: 200, cost: 200,
clicks: 200, impressions: 2000, conversions: 10, salesOrganic: 0, unitsOrganic: 0,
paidSalesShare: 100, organicSalesShare: 0, acos: 10, tacos: 10, roas: 10, ctr: 10, cpc: 1, cvrUnits: 5, glanceViews: 2000
}
];
const experiment: Experiment = {
id: 'exp1',
name: 'Test Exp',
type: 'advertising',
status: 'active',
asins: ['B0C123'],
control_asins: [],
marketplace: 'DE',
start_date: '2024-01-20',
end_date: '2024-02-28',
baseline_start_date: '2023-12-01',
baseline_end_date: '2024-01-19',
created_at: '',
updated_at: '',
primary_metric: 'acos',
changes: []
};
const result = computeDiD(experiment, salesData);
console.log(JSON.stringify(result.metrics, null, 2));
+1 -118
View File
@@ -207,7 +207,7 @@ export interface CombinedKPIs {
cpc: number;
cvrUnits: number;
glanceViews: number; // Traffic / page views
detailLevelBSR?: number; // Added for Detail Level BSR in Experiments
detailLevelBSR?: number;
avgWeeklySales?: number; // Added for Weeks of Coverage
}
@@ -293,120 +293,3 @@ export interface BSRRecord {
avgRating: number | null;
}
// Experiment Tracking Types
export type ExperimentType = 'pricing' | 'advertising' | 'content' | 'promotion' | 'seo';
export type ExperimentStatus = 'planned' | 'active' | 'completed' | 'paused';
export type ExperimentMetric = 'units' | 'sessions' | 'cvr' | 'ctr' | 'roas' | 'revenue' | 'acos' | 'detail_bsr';
export type ExperimentVerdict = 'winner' | 'loser' | 'inconclusive';
export interface ExperimentChangeAnnotation {
field: string;
before_value: string;
after_value: string;
}
export interface DiDMetricResult {
treatment_before: number;
treatment_after: number;
control_before: number;
control_after: number;
did_estimate: number;
lift_percent: number;
posterior_prob_positive: number;
}
export interface DifferenceInDifferencesResult {
metrics: Record<string, DiDMetricResult>;
computed_at: string;
}
export interface Experiment {
id: string;
name: string;
description?: string;
type: ExperimentType;
status: ExperimentStatus;
asins: string[];
control_asins: string[];
marketplace: string;
start_date: string;
end_date?: string;
baseline_start_date?: string;
baseline_end_date?: string;
created_at: string;
updated_at: string;
hypothesis?: string;
primary_metric: ExperimentMetric;
target_lift_percent?: number;
changes: ExperimentChangeAnnotation[];
// Legacy results (kept for backward compat)
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;
// DiD results
did_results?: DifferenceInDifferencesResult;
verdict?: ExperimentVerdict;
verdict_probability?: number;
learnings?: string;
owner?: string;
}
export interface ExperimentCreateInput {
name: string;
description?: string;
type: ExperimentType;
asins: string[];
control_asins: string[];
marketplace: string;
start_date: string;
end_date?: string;
baseline_start_date?: string;
baseline_end_date?: string;
hypothesis?: string;
primary_metric: ExperimentMetric;
target_lift_percent?: number;
changes: ExperimentChangeAnnotation[];
owner?: string;
}
export interface ExperimentListItem {
id: string;
name: string;
type: ExperimentType;
status: ExperimentStatus;
asin_count: number;
asins: string[];
control_asin_count: number;
marketplace: string;
start_date: string;
end_date?: string;
progress_percent: number;
primary_metric: ExperimentMetric;
verdict?: ExperimentVerdict;
verdict_probability?: number;
actual_lift_percent?: number;
owner?: string;
}
export interface ActiveExperiment {
asin: string;
experiment_id: string;
experiment_name: string;
type: ExperimentType;
status: ExperimentStatus;
start_date: string;
end_date?: string;
days_remaining?: number;
}