From 3e3975cd2bea303707c88a5a3fdd292bacf671a1 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Mon, 9 Mar 2026 10:58:01 +0100 Subject: [PATCH] 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 --- App.tsx | 2 +- components/BSRUnitsCorrelationChart.tsx | 325 ++++++++++++++++++++++++ components/VendorDataView.tsx | 9 +- 3 files changed, 333 insertions(+), 3 deletions(-) create mode 100644 components/BSRUnitsCorrelationChart.tsx diff --git a/App.tsx b/App.tsx index 2c67b9e..77f4fcf 100644 --- a/App.tsx +++ b/App.tsx @@ -916,7 +916,7 @@ const App: React.FC = () => { }>
- +
diff --git a/components/BSRUnitsCorrelationChart.tsx b/components/BSRUnitsCorrelationChart.tsx new file mode 100644 index 0000000..811cc4a --- /dev/null +++ b/components/BSRUnitsCorrelationChart.tsx @@ -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 = { + '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 = ({ bsrData, combinedSalesData }) => { + const [activeMarket, setActiveMarket] = useState('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(); + 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(); + 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 ( +
+ + {/* Header row */} +
+
+

BSR vs Units Sold

+

+ Weekly correlation ยท {currentYear} ยท Top-level category BSR +

+
+ +
+ + {/* Marketplace tabs */} + {availableMarkets.length > 0 && ( +
+ {availableMarkets.map(m => { + const conf = MARKET_CONFIG[m]; + const isActive = m === resolvedMarket; + return ( + + ); + })} +
+ )} + + {/* Chart */} + {chartData.length === 0 ? ( +
+ No data available for {config.flag} {config.label} +
+ ) : ( +
+ + + + + + {/* Left axis: BSR rank */} + + + {/* Right axis: units sold */} + 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 }, + }} + /> + + + + {/* Units โ€” bars on right axis */} + + + {/* BSR โ€” line on left axis */} + + + +
+ )} + + {/* Legend */} +
+
+
+ Units Sold +
+
+
+
+ BSR Rank +
+
+ + {/* Pearson correlation note */} +
+
+ Pearson r + + {pearsonInfo.text} + + + {pearsonInfo.label} + +
+ + {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' + : ''} + +
+ +
+ ); +}; diff --git a/components/VendorDataView.tsx b/components/VendorDataView.tsx index 8340de7..b757ca4 100644 --- a/components/VendorDataView.tsx +++ b/components/VendorDataView.tsx @@ -1,12 +1,14 @@ import React, { useMemo } from 'react'; import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts'; -import { BSRRecord } from '../types'; +import { BSRRecord, CombinedKPIs } from '../types'; import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; +import { BSRUnitsCorrelationChart } from './BSRUnitsCorrelationChart'; interface VendorDataViewProps { bsrData: BSRRecord[]; asinMetadata?: Map; buyBoxLostMap?: Map }>; + combinedSalesData?: CombinedKPIs[]; } interface ChartPoint { @@ -304,7 +306,7 @@ const CATEGORY_TRANSLATIONS: Record = { const translateCategory = (name: string): string => CATEGORY_TRANSLATIONS[name] ?? name; -const VendorDataView: React.FC = ({ bsrData = [], asinMetadata, buyBoxLostMap }) => { +const VendorDataView: React.FC = ({ bsrData = [], asinMetadata, buyBoxLostMap, combinedSalesData = [] }) => { const activeMarkets = useMemo(() => { const m = new Set(bsrData.map(r => r.market)); return Array.from(m).sort(); @@ -466,6 +468,9 @@ const VendorDataView: React.FC = ({ bsrData = [], asinMetad
)} + {/* BSR vs Units Correlation Chart */} + + {/* Top Level BSR Trend Chart */}