mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 16:25:23 +02:00
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
349 lines
14 KiB
TypeScript
349 lines
14 KiB
TypeScript
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 BSR–Units 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>
|
||
);
|
||
};
|