feat: implement country-aware Top 50 ranking

- App.tsx: Update top50Ranking2025 to calculate ranks based on active country filters
- App.tsx: Add split Pan-EU vs UK ranking when no filters are active
- WeeklyGrid.tsx: Add support for multiple ranking badges (EU/UK)
- WeeklyGrid.tsx: Enhance Top50Badge with labels and color themes
- WeeklyGrid.tsx: Update sorting and filtering logic for new ranking structure
This commit is contained in:
Christian Vidal Wolf
2026-01-22 13:01:04 +01:00
parent 8005a99a48
commit 0214509ffe
2 changed files with 102 additions and 34 deletions
+24 -7
View File
@@ -254,31 +254,48 @@ const App: React.FC = () => {
const filteredAdsData = useMemo(() => filterAdsData(adsData, filters), [adsData, filters]); const filteredAdsData = useMemo(() => filterAdsData(adsData, filters), [adsData, filters]);
const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]); const aggregatedData = useMemo(() => aggregateData(filteredData), [filteredData]);
// Calculate static Top 50 Best Sellers for 2025 (independent of current filters) // Calculate country-aware Top 50 Best Sellers for 2025
const top50Ranking2025 = useMemo(() => { const top50Ranking2025 = useMemo(() => {
// Filter only 2025 data // Filter only 2025 data
const data2025 = rawData.filter(r => r.year === 2025); const data2025 = rawData.filter(r => r.year === 2025);
// Aggregate Sell Out by ASIN const calculateTop50 = (entries: SalesRecord[]) => {
const asinTotals = new Map<string, number>(); const asinTotals = new Map<string, number>();
data2025.forEach(r => { entries.forEach(r => {
const asin = r.asin.trim().toUpperCase(); const asin = r.asin.trim().toUpperCase();
asinTotals.set(asin, (asinTotals.get(asin) || 0) + r.sellOut); asinTotals.set(asin, (asinTotals.get(asin) || 0) + r.sellOut);
}); });
// Sort by total Sell Out descending and take top 50
const sorted = Array.from(asinTotals.entries()) const sorted = Array.from(asinTotals.entries())
.sort((a, b) => b[1] - a[1]) .sort((a, b) => b[1] - a[1])
.slice(0, 50); .slice(0, 50);
// Create a Map of ASIN -> rank (1-50)
const rankMap = new Map<string, number>(); const rankMap = new Map<string, number>();
sorted.forEach(([asin], index) => { sorted.forEach(([asin], index) => {
rankMap.set(asin, index + 1); rankMap.set(asin, index + 1);
}); });
return rankMap; return rankMap;
}, [rawData]); };
if (filters.customer.length > 0) {
// Filtered mode: Rank products based on currently selected countries
const filtered2025 = data2025.filter(r => filters.customer.includes(r.customer));
return {
type: 'filtered' as const,
overall: calculateTop50(filtered2025)
};
} else {
// Dual mode: Separate Pan-EU and UK rankings when no countries are selected
const euData = data2025.filter(r => !r.customer.toLowerCase().includes('uk'));
const ukData = data2025.filter(r => r.customer.toLowerCase().includes('uk'));
return {
type: 'dual' as const,
eu: calculateTop50(euData),
uk: calculateTop50(ukData)
};
}
}, [rawData, filters.customer]);
// Combine Sales & Ads Data dynamically based on current filters // Combine Sales & Ads Data dynamically based on current filters
const combinedAdsData = useMemo(() => { const combinedAdsData = useMemo(() => {
+62 -11
View File
@@ -5,7 +5,12 @@ import { pivotWeeklySalesData, WeeklyPivotRow } from '../services/dataProcessor'
interface WeeklyGridProps { interface WeeklyGridProps {
data: CombinedKPIs[]; data: CombinedKPIs[];
top50Ranking?: Map<string, number>; // Static ranking of Top 50 ASINs by 2025 Sell Out top50Ranking?: {
type: 'filtered' | 'dual';
overall?: Map<string, number>;
eu?: Map<string, number>;
uk?: Map<string, number>;
};
} }
type SortConfig = { type SortConfig = {
@@ -28,14 +33,24 @@ const useDebounce = (value: string, delay: number) => {
}; };
// Top 50 Badge Component // Top 50 Badge Component
const Top50Badge: React.FC<{ rank: number }> = ({ rank }) => ( const Top50Badge: React.FC<{ rank: number; label?: string; theme?: 'amber' | 'blue' | 'indigo' }> = ({ rank, label, theme = 'amber' }) => {
const themeClasses = {
amber: 'from-amber-500 to-orange-500 border-amber-400/50',
blue: 'from-blue-500 to-cyan-500 border-blue-400/50',
indigo: 'from-indigo-500 to-purple-500 border-indigo-400/50',
};
return (
<span <span
className="inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[9px] font-black bg-gradient-to-r from-amber-500 to-orange-500 text-white shadow-sm border border-amber-400/50" className={`inline-flex items-center justify-center px-1.5 py-0.5 rounded text-[9px] font-black bg-gradient-to-r ${themeClasses[theme]} text-white shadow-sm border`}
title={`Top ${rank} Best Seller 2025`} title={`Top ${rank} Best Seller 2025 ${label ? `(${label})` : ''}`}
> >
🏆 {rank} <span className="mr-0.5">🏆</span>
{label && <span className="mr-0.5 opacity-90">{label}</span>}
{rank}
</span> </span>
); );
};
const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => { const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
// Pivot data - memoized // Pivot data - memoized
@@ -96,7 +111,14 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
// Top 50 Filter // Top 50 Filter
if (showOnlyTop50 && top50Ranking) { if (showOnlyTop50 && top50Ranking) {
result = result.filter(r => top50Ranking.has(r.asin.trim().toUpperCase())); result = result.filter(r => {
const asin = r.asin.trim().toUpperCase();
if (top50Ranking.type === 'filtered') {
return top50Ranking.overall?.has(asin);
} else {
return top50Ranking.eu?.has(asin) || top50Ranking.uk?.has(asin);
}
});
} }
// Search Filter // Search Filter
@@ -150,8 +172,21 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
result.sort((a, b) => { result.sort((a, b) => {
if (sortConfig.metric === 'rank') { if (sortConfig.metric === 'rank') {
const rankA = top50Ranking?.get(a.asin.trim().toUpperCase()) || 999; const asinA = a.asin.trim().toUpperCase();
const rankB = top50Ranking?.get(b.asin.trim().toUpperCase()) || 999; const asinB = b.asin.trim().toUpperCase();
let rankA = 999;
let rankB = 999;
if (top50Ranking?.type === 'filtered') {
rankA = top50Ranking.overall?.get(asinA) || 999;
rankB = top50Ranking.overall?.get(asinB) || 999;
} else if (top50Ranking?.type === 'dual') {
// In dual mode, prioritize EU rank, then UK rank
rankA = top50Ranking.eu?.get(asinA) || top50Ranking.uk?.get(asinA) || 999;
rankB = top50Ranking.eu?.get(asinB) || top50Ranking.uk?.get(asinB) || 999;
}
return direction === 'asc' ? rankA - rankB : rankB - rankA; return direction === 'asc' ? rankA - rankB : rankB - rankA;
} }
const valA = a[metricKey][weekKey] || 0; const valA = a[metricKey][weekKey] || 0;
@@ -387,13 +422,29 @@ const WeeklyGrid: React.FC<WeeklyGridProps> = ({ data, top50Ranking }) => {
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
{paginatedRows.length > 0 ? ( {paginatedRows.length > 0 ? (
paginatedRows.map((row) => { paginatedRows.map((row) => {
const top50Rank = top50Ranking?.get(row.asin.trim().toUpperCase()); const asin = row.asin.trim().toUpperCase();
const ranks = [];
if (top50Ranking) {
if (top50Ranking.type === 'filtered') {
const rank = top50Ranking.overall?.get(asin);
if (rank) ranks.push({ rank, label: '', theme: 'amber' as const });
} else {
const euRank = top50Ranking.eu?.get(asin);
const ukRank = top50Ranking.uk?.get(asin);
if (euRank) ranks.push({ rank: euRank, label: 'EU', theme: 'indigo' as const });
if (ukRank) ranks.push({ rank: ukRank, label: 'UK', theme: 'blue' as const });
}
}
return ( return (
<tr key={row.id} className="hover:bg-white/[0.02] transition-colors group"> <tr key={row.id} className="hover:bg-white/[0.02] transition-colors group">
<td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10"> <td className="p-3 py-2 sticky left-0 z-10 bg-slate-900 group-hover:bg-slate-800 border-r border-white/10">
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex items-center gap-2 mb-0.5"> <div className="flex items-center gap-2 mb-0.5">
{top50Rank && <Top50Badge rank={top50Rank} />} {ranks.map((r, i) => (
<Top50Badge key={i} rank={r.rank} label={r.label} theme={r.theme} />
))}
<span className="text-xs font-black text-indigo-400 uppercase tracking-tighter truncate max-w-[120px]">{row.sku || '-'}</span> <span className="text-xs font-black text-indigo-400 uppercase tracking-tighter truncate max-w-[120px]">{row.sku || '-'}</span>
<span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span> <span className="text-[10px] font-bold text-slate-500 bg-slate-800 px-1.5 py-0.5 rounded border border-white/5">{row.asin}</span>
</div> </div>