mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 10:05:23 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
9f9c0c2975
commit
76d901fcde
@@ -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 >
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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
@@ -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 */}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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'}`}>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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,
|
||||
}
|
||||
];
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
@@ -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
@@ -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));
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user