Files
CrazeAnalytix/components/BSRUnitsCorrelationChart.tsx
T

349 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useMemo } from 'react';
import {
ComposedChart, Bar, Line,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from 'recharts';
import { BSRRecord, CombinedKPIs } from '../types';
interface Props {
bsrData: BSRRecord[];
combinedSalesData: CombinedKPIs[];
}
const MARKETS = ['Amazon DE', 'Amazon ES', 'Amazon FR', 'Amazon IT', 'Amazon UK'] as const;
const MARKET_CONFIG: Record<string, { label: string; color: string; flag: string }> = {
'Amazon DE': { label: 'DE', color: '#f37526', flag: '🇩🇪' },
'Amazon ES': { label: 'ES', color: '#2acbd6', flag: '🇪🇸' },
'Amazon FR': { label: 'FR', color: '#7950f2', flag: '🇫🇷' },
'Amazon IT': { label: 'IT', color: '#10b981', flag: '🇮🇹' },
'Amazon UK': { label: 'UK', color: '#f59e0b', flag: '🇬🇧' },
};
function pearsonR(x: number[], y: number[]): number | null {
const n = x.length;
if (n < 2 || n !== y.length) return null;
const meanX = x.reduce((a, b) => a + b, 0) / n;
const meanY = y.reduce((a, b) => a + b, 0) / n;
const num = x.reduce((s, xi, i) => s + (xi - meanX) * (y[i] - meanY), 0);
const den = Math.sqrt(
x.reduce((s, xi) => s + (xi - meanX) ** 2, 0) *
y.reduce((s, yi) => s + (yi - meanY) ** 2, 0)
);
return den === 0 ? null : num / den;
}
const formatBSR = (v: number) =>
v >= 1_000_000
? `${(v / 1_000_000).toFixed(1).replace(/\.0$/, '')}M`
: v >= 1_000
? `${Math.round(v / 1_000)}K`
: String(v);
const CARD_BG = '#161b24';
const TOOLTIP_STYLE = {
backgroundColor: '#0f172a',
border: '1px solid #1e293b',
borderRadius: '10px',
color: '#fff',
fontSize: '12px',
};
export const BSRUnitsCorrelationChart: React.FC<Props> = ({ bsrData, combinedSalesData }) => {
const [activeMarket, setActiveMarket] = useState<string>('Amazon DE');
const [bsrInverted, setBsrInverted] = useState(true);
// Derive available markets from actual data
const availableMarkets = useMemo(() => {
const bsrMarkets = new Set(bsrData.map(r => r.market));
const salesMarkets = new Set(combinedSalesData.map(r => r.customer));
return MARKETS.filter(m => bsrMarkets.has(m) || salesMarkets.has(m));
}, [bsrData, combinedSalesData]);
// Ensure activeMarket is always valid when data changes
const resolvedMarket = availableMarkets.includes(activeMarket as any)
? activeMarket
: (availableMarkets[0] ?? 'Amazon DE');
// Current year from sales data — use reduce to avoid call stack overflow with large arrays
const currentYear = useMemo(() => {
let max = 0;
for (const r of combinedSalesData) {
if (r.year && r.year > max) max = r.year;
}
return max > 0 ? max : new Date().getFullYear();
}, [combinedSalesData]);
// Per-market chart data: BSR avg + units sum aligned by week
const { chartData, pearsonCorrelation, debugInfo } = useMemo(() => {
// BSR: average detailLevelBSR per week (falls back to topLevelBSR if detail is unavailable)
// Coerce week to number to guard against string values at runtime
const bsrByWeek = new Map<number, { sum: number; count: number }>();
const bsrRecordsForMarket = bsrData.filter(r => r.market === resolvedMarket);
bsrRecordsForMarket
.filter(r => r.detailLevelBSR != null || r.topLevelBSR != null)
.forEach(r => {
const bsr = r.detailLevelBSR ?? r.topLevelBSR!;
const week = Number(r.week);
const e = bsrByWeek.get(week) ?? { sum: 0, count: 0 };
bsrByWeek.set(week, { sum: e.sum + bsr, count: e.count + 1 });
});
// Units: sum unitsTotal per week for current year
const unitsByWeek = new Map<number, number>();
combinedSalesData
.filter(r => r.customer === resolvedMarket && r.year === currentYear && r.week)
.forEach(r => {
const week = Number(r.week);
unitsByWeek.set(week, (unitsByWeek.get(week) ?? 0) + r.unitsTotal);
});
const debugInfo = {
bsrTotal: bsrData.length,
bsrForMarket: bsrRecordsForMarket.length,
bsrWithValues: bsrByWeek.size,
unitsWeeks: unitsByWeek.size,
uniqueMarkets: Array.from(new Set(bsrData.map(r => r.market))).join(', '),
};
const allWeeks = Array.from(
new Set([...bsrByWeek.keys(), ...unitsByWeek.keys()])
).sort((a, b) => a - b);
const chartData = allWeeks.map(week => {
const bsrEntry = bsrByWeek.get(week);
return {
week: `W${String(week).padStart(2, '0')}`,
weekNum: week,
bsr: bsrEntry ? Math.round(bsrEntry.sum / bsrEntry.count) : null,
units: unitsByWeek.get(week) ?? null,
};
});
// Pearson only for weeks where both values exist
const paired = chartData.filter(d => d.bsr !== null && d.units !== null);
const r = pearsonR(
paired.map(d => d.bsr as number),
paired.map(d => d.units as number)
);
return { chartData, pearsonCorrelation: r, debugInfo };
}, [resolvedMarket, bsrData, combinedSalesData, currentYear]);
const config = MARKET_CONFIG[resolvedMarket] ?? { label: '?', color: '#94a3b8', flag: '🌍' };
const pearsonInfo = useMemo(() => {
if (pearsonCorrelation === null) return { text: '', label: 'Insufficient data', color: '#475569' };
const abs = Math.abs(pearsonCorrelation);
const sign = pearsonCorrelation < 0 ? 'negative' : 'positive';
const strength = abs >= 0.7 ? 'Strong' : abs >= 0.4 ? 'Moderate' : 'Weak';
// Negative r is GOOD: lower BSR (better rank) → more units
const baseColor = pearsonCorrelation < 0
? (abs >= 0.7 ? '#10b981' : abs >= 0.4 ? '#6ee7b7' : '#94a3b8')
: (abs >= 0.7 ? '#ef4444' : abs >= 0.4 ? '#fca5a5' : '#94a3b8');
return {
text: pearsonCorrelation.toFixed(2),
label: `${strength} ${sign}`,
color: baseColor,
};
}, [pearsonCorrelation]);
const tooltipFormatter = (value: unknown, name: string) => {
if (name === 'BSR') return [typeof value === 'number' ? value.toLocaleString('de-DE') : '', 'Detail BSR'];
if (name === 'Units') return [typeof value === 'number' ? value.toLocaleString('de-DE') : '', 'Units Sold'];
return [value, name];
};
return (
<div className="bg-[#161b24] border border-[#2a3040] rounded-2xl p-6 shadow-md flex flex-col gap-5">
{/* Header row */}
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-[17px] font-semibold text-white tracking-wide">BSR vs Units Sold</h3>
<p className="text-[13px] text-[#64748b] mt-0.5">
Weekly correlation · {currentYear} · Detail category BSR
</p>
</div>
<button
onClick={() => setBsrInverted(v => !v)}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-[#2a3040] bg-[#1f2937] text-[11px] font-semibold text-slate-300 hover:border-slate-600 hover:text-white transition-all"
>
<span className="text-base leading-none">{bsrInverted ? '↑' : '↓'}</span>
<span>BSR: {bsrInverted ? 'Lower = Top' : 'Lower = Bottom'}</span>
</button>
</div>
{/* Marketplace tabs */}
{availableMarkets.length > 0 && (
<div className="flex items-center gap-1.5 flex-wrap">
{availableMarkets.map(m => {
const conf = MARKET_CONFIG[m];
const isActive = m === resolvedMarket;
return (
<button
key={m}
onClick={() => setActiveMarket(m)}
className="px-3.5 py-1.5 rounded-lg text-[12px] font-bold uppercase tracking-wider transition-all border"
style={
isActive
? { color: conf.color, borderColor: conf.color, backgroundColor: `${conf.color}1a` }
: { color: '#64748b', borderColor: 'transparent' }
}
onMouseEnter={e => {
if (!isActive) (e.currentTarget as HTMLButtonElement).style.color = '#cbd5e1';
}}
onMouseLeave={e => {
if (!isActive) (e.currentTarget as HTMLButtonElement).style.color = '#64748b';
}}
>
{conf.flag} {conf.label}
</button>
);
})}
</div>
)}
{/* Chart */}
{chartData.length === 0 ? (
<div className="h-[260px] flex items-center justify-center text-[#475569] text-sm italic">
No data available for {config.flag} {config.label}
</div>
) : (
<div className="h-[260px] w-full">
<ResponsiveContainer width="100%" height="100%">
<ComposedChart data={chartData} margin={{ top: 16, right: 54, left: 8, bottom: 4 }}>
<CartesianGrid
strokeDasharray="0"
stroke="#ffffff"
strokeOpacity={0.03}
vertical={false}
/>
<XAxis
dataKey="week"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 11, fontWeight: 500 }}
dy={8}
/>
{/* Left axis: BSR rank */}
<YAxis
yAxisId="bsr"
orientation="left"
reversed={bsrInverted}
domain={['auto', 'auto']}
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 10 }}
tickFormatter={formatBSR}
width={48}
label={{
value: 'BSR Rank',
angle: -90,
position: 'insideLeft',
offset: 6,
style: { fill: '#475569', fontSize: 10, fontWeight: 600 },
}}
/>
{/* Right axis: units sold */}
<YAxis
yAxisId="units"
orientation="right"
axisLine={false}
tickLine={false}
tick={{ fill: '#64748b', fontSize: 10 }}
tickFormatter={(v: number) => v >= 1_000 ? `${Math.round(v / 1_000)}K` : String(v)}
width={48}
label={{
value: 'Units Sold',
angle: 90,
position: 'insideRight',
offset: 8,
style: { fill: '#475569', fontSize: 10, fontWeight: 600 },
}}
/>
<Tooltip
contentStyle={TOOLTIP_STYLE}
itemStyle={{ fontSize: '12px' }}
labelStyle={{ color: '#94a3b8', fontSize: '11px', marginBottom: '4px' }}
cursor={{ stroke: '#334155', strokeWidth: 1, strokeDasharray: '3 3' }}
formatter={tooltipFormatter}
/>
{/* Units — bars on right axis */}
<Bar
yAxisId="units"
dataKey="units"
name="Units"
fill={config.color}
fillOpacity={0.45}
radius={[3, 3, 0, 0]}
maxBarSize={36}
/>
{/* BSR — line on left axis */}
<Line
yAxisId="bsr"
type="monotone"
dataKey="bsr"
name="BSR"
stroke={config.color}
strokeWidth={2.5}
dot={{ r: 4, fill: CARD_BG, stroke: config.color, strokeWidth: 2 }}
activeDot={{ r: 6, strokeWidth: 0, fill: config.color }}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
</div>
)}
{/* Legend */}
<div className="flex items-center gap-5 justify-center text-[11px] text-[#64748b]">
<div className="flex items-center gap-1.5">
<div className="w-8 h-2.5 rounded-sm" style={{ backgroundColor: config.color, opacity: 0.55 }} />
<span>Units Sold</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-6 h-0.5 rounded" style={{ backgroundColor: config.color }} />
<div className="w-2 h-2 rounded-full border-2" style={{ borderColor: config.color, backgroundColor: CARD_BG }} />
<span>BSR Rank</span>
</div>
</div>
{/* Pearson correlation note */}
<div className="flex flex-wrap items-center gap-4 px-4 py-3 rounded-xl bg-[#111827] border border-[#1e293b]">
<div className="flex items-center gap-2.5">
<span className="text-[10px] font-black uppercase tracking-widest text-[#334155]">Pearson r</span>
<span className="text-[22px] font-black tabular-nums" style={{ color: pearsonInfo.color }}>
{pearsonInfo.text}
</span>
<span className="text-[11px] font-semibold" style={{ color: pearsonInfo.color }}>
{pearsonInfo.label}
</span>
</div>
<span className="text-[10px] text-[#334155] ml-auto">
{pearsonCorrelation !== null && pearsonCorrelation < -0.3
? '↓ Rank improvement correlates with ↑ Units sold'
: pearsonCorrelation !== null && pearsonCorrelation > 0.3
? '↑ Rank number correlates with ↑ Units — unusual'
: pearsonCorrelation !== null
? 'No strong BSRUnits relationship detected'
: ''}
</span>
</div>
{/* Temporary debug strip — remove once BSR line is confirmed working */}
<div className="px-3 py-2 rounded-lg bg-slate-900/60 border border-slate-800 text-[10px] font-mono text-slate-500 space-y-0.5">
<div>BSR records total: <span className="text-slate-300">{debugInfo.bsrTotal}</span> · for {resolvedMarket}: <span className="text-slate-300">{debugInfo.bsrForMarket}</span> · with values: <span className="text-slate-300">{debugInfo.bsrWithValues} weeks</span></div>
<div>Units weeks found: <span className="text-slate-300">{debugInfo.unitsWeeks}</span> · year: <span className="text-slate-300">{currentYear}</span></div>
<div>Markets in BSR data: <span className="text-slate-300">{debugInfo.uniqueMarkets || '(none)'}</span></div>
</div>
</div>
);
};