mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 14:45:24 +02:00
feat: add BSR vs Units correlation chart to Vendor tab
New BSRUnitsCorrelationChart component added to the Vendor tab showing: - Per-marketplace tabs (DE, ES, FR, IT, UK) with market accent colors - Dual-axis combo chart: bars (units sold, right axis) + line (BSR rank, left axis) - Inverted BSR axis by default (lower rank = top) with toggle to flip - Pearson r correlation calculated and displayed with strength interpretation - Wired to existing combinedAdsData (filtered) and filteredBsrData Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
507af94e1a
commit
3e3975cd2b
@@ -916,7 +916,7 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
<Suspense fallback={<LoadingSpinner />}>
|
<Suspense fallback={<LoadingSpinner />}>
|
||||||
<div className={view === 'vendor' ? '' : 'hidden'}>
|
<div className={view === 'vendor' ? '' : 'hidden'}>
|
||||||
<VendorDataView bsrData={filteredBsrData} asinMetadata={globalAsinMetadata} buyBoxLostMap={buyBoxLostMap} />
|
<VendorDataView bsrData={filteredBsrData} asinMetadata={globalAsinMetadata} buyBoxLostMap={buyBoxLostMap} combinedSalesData={combinedAdsData} />
|
||||||
</div>
|
</div>
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,325 @@
|
|||||||
|
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
|
||||||
|
const currentYear = useMemo(() => {
|
||||||
|
const years = combinedSalesData.map(r => r.year).filter(Boolean);
|
||||||
|
return years.length > 0 ? Math.max(...years) : new Date().getFullYear();
|
||||||
|
}, [combinedSalesData]);
|
||||||
|
|
||||||
|
// Per-market chart data: BSR avg + units sum aligned by week
|
||||||
|
const { chartData, pearsonCorrelation } = useMemo(() => {
|
||||||
|
// BSR: average topLevelBSR per week
|
||||||
|
const bsrByWeek = new Map<number, { sum: number; count: number }>();
|
||||||
|
bsrData
|
||||||
|
.filter(r => r.market === resolvedMarket && r.topLevelBSR != null)
|
||||||
|
.forEach(r => {
|
||||||
|
const e = bsrByWeek.get(r.week) ?? { sum: 0, count: 0 };
|
||||||
|
bsrByWeek.set(r.week, { sum: e.sum + r.topLevelBSR!, 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 => {
|
||||||
|
unitsByWeek.set(r.week, (unitsByWeek.get(r.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') : '–', 'BSR Rank'];
|
||||||
|
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} · Top-level 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>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
import { BSRRecord } from '../types';
|
import { BSRRecord, CombinedKPIs } from '../types';
|
||||||
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
import { BuyBoxWarningBadge } from './BuyBoxWarningBadge';
|
||||||
|
import { BSRUnitsCorrelationChart } from './BSRUnitsCorrelationChart';
|
||||||
|
|
||||||
interface VendorDataViewProps {
|
interface VendorDataViewProps {
|
||||||
bsrData: BSRRecord[];
|
bsrData: BSRRecord[];
|
||||||
asinMetadata?: Map<string, { sku: string; title: string; line: string }>;
|
asinMetadata?: Map<string, { sku: string; title: string; line: string }>;
|
||||||
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
buyBoxLostMap?: Map<string, { countries: string[]; reasons: Record<string, string> }>;
|
||||||
|
combinedSalesData?: CombinedKPIs[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ChartPoint {
|
interface ChartPoint {
|
||||||
@@ -304,7 +306,7 @@ const CATEGORY_TRANSLATIONS: Record<string, string> = {
|
|||||||
const translateCategory = (name: string): string =>
|
const translateCategory = (name: string): string =>
|
||||||
CATEGORY_TRANSLATIONS[name] ?? name;
|
CATEGORY_TRANSLATIONS[name] ?? name;
|
||||||
|
|
||||||
const VendorDataView: React.FC<VendorDataViewProps> = ({ bsrData = [], asinMetadata, buyBoxLostMap }) => {
|
const VendorDataView: React.FC<VendorDataViewProps> = ({ bsrData = [], asinMetadata, buyBoxLostMap, combinedSalesData = [] }) => {
|
||||||
const activeMarkets = useMemo(() => {
|
const activeMarkets = useMemo(() => {
|
||||||
const m = new Set(bsrData.map(r => r.market));
|
const m = new Set(bsrData.map(r => r.market));
|
||||||
return Array.from(m).sort();
|
return Array.from(m).sort();
|
||||||
@@ -466,6 +468,9 @@ const VendorDataView: React.FC<VendorDataViewProps> = ({ bsrData = [], asinMetad
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* BSR vs Units Correlation Chart */}
|
||||||
|
<BSRUnitsCorrelationChart bsrData={bsrData} combinedSalesData={combinedSalesData} />
|
||||||
|
|
||||||
{/* Top Level BSR Trend Chart */}
|
{/* Top Level BSR Trend Chart */}
|
||||||
<div className="bg-[#161b24] border border-[#2a3040] rounded-2xl p-6 shadow-md flex flex-col">
|
<div className="bg-[#161b24] border border-[#2a3040] rounded-2xl p-6 shadow-md flex flex-col">
|
||||||
<div className="mb-4">
|
<div className="mb-4">
|
||||||
|
|||||||
Reference in New Issue
Block a user