Files
CrazeAnalytix/components/BSRUnitsCorrelationChart.tsx
Christian Vidal WolfandClaude Sonnet 4.6 0e1457bd3d fix: BSR rank line now shows in BSR vs Units Sold chart
BSRRecord.market is stored as short codes ('DE', 'IT', etc.) from the
Excel file, but the chart was comparing against full names ('Amazon DE',
'Amazon IT', etc.) — causing bsrRecordsForMarket to always be empty and
the BSR line to never render. Added normalizeBsrMarket() to map short
codes to full names before filtering. Also removed the debug strip.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 09:08:30 +01:00

340 lines
13 KiB
TypeScript
Raw Permalink 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;
// BSR Excel stores market as short codes ('DE', 'IT', …); normalize to full names
const BSR_MARKET_NORMALIZE: Record<string, string> = {
'DE': 'Amazon DE', 'ES': 'Amazon ES', 'FR': 'Amazon FR', 'IT': 'Amazon IT', 'UK': 'Amazon UK',
};
const normalizeBsrMarket = (m: string) => BSR_MARKET_NORMALIZE[m.toUpperCase()] ?? m;
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 => normalizeBsrMarket(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 } = 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 => normalizeBsrMarket(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 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 };
}, [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>
</div>
);
};