From 25756ae763f7e2a864e2450c667144ed848fffa5 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Thu, 22 Jan 2026 13:37:07 +0100 Subject: [PATCH] feat: implement period-over-period comparison in Ads tab - AdsPerformance.tsx: Automatically activate comparison view when 2 periods are selected - AdsPerformance.tsx: Add growth percentages and trend indicators to table and summary - AdsPerformance.tsx: Implement chronological sorting for previous vs current periods - App.tsx: Pass filters to AdsPerformance component --- App.tsx | 2 +- components/AdsPerformance.tsx | 257 ++++++++++++++++++++++------------ 2 files changed, 172 insertions(+), 87 deletions(-) diff --git a/App.tsx b/App.tsx index b41ba79..cce56a0 100644 --- a/App.tsx +++ b/App.tsx @@ -493,7 +493,7 @@ const App: React.FC = () => { {view === 'table' && 0} adsData={filteredAdsData} />} {view === 'weekly' && } {view === 'movers' && } - {view === 'ads' && } + {view === 'ads' && } diff --git a/components/AdsPerformance.tsx b/components/AdsPerformance.tsx index e9613f7..92f52a3 100644 --- a/components/AdsPerformance.tsx +++ b/components/AdsPerformance.tsx @@ -1,65 +1,110 @@ import React, { useMemo, useState } from 'react'; -import { CombinedKPIs } from '../types'; +import { CombinedKPIs, FilterState } from '../types'; interface AdsPerformanceProps { data: CombinedKPIs[]; + filters: FilterState; } type SortKey = keyof CombinedKPIs | 'acos' | 'roas' | 'tacos' | 'ctr' | 'cpc' | 'cvrUnits'; -const AdsPerformance: React.FC = ({ data }) => { +const AdsPerformance: React.FC = ({ data, filters }) => { const [searchTerm, setSearchTerm] = useState(''); const [sortConfig, setSortConfig] = useState<{ key: SortKey; direction: 'asc' | 'desc' }>({ key: 'cost', direction: 'desc' }); - // 1. Aggregate data by ASIN + // Detect comparison mode: exactly 2 weeks, 2 months, or 2 years + const comparisonConfig = useMemo(() => { + let periods: string[] = []; + let type: 'week' | 'month' | 'year' | null = null; + + if (filters.week.length === 2) { + periods = [...filters.week].sort((a, b) => { + const wA = parseInt(a.replace('W', '')); + const wB = parseInt(b.replace('W', '')); + return wA - wB; + }); + type = 'week'; + } else if (filters.month.length === 2) { + // Simple sort for abbreviated months might be tricky without year, + // but usually users pick consecutive ones. + periods = [...filters.month]; + type = 'month'; + } else if (filters.year.length === 2) { + periods = [...filters.year].sort(); + type = 'year'; + } + + if (type && periods.length === 2) { + return { type, prev: periods[0], curr: periods[1] }; + } + return null; + }, [filters]); + + // 1. Aggregate and Compare data by ASIN const aggregatedByAsin = useMemo(() => { - const map = new Map(); + const map = new Map(); data.forEach(item => { const asin = item.asin.trim().toUpperCase(); + const periodVal = comparisonConfig?.type === 'week' ? `W${item.week}` : + comparisonConfig?.type === 'month' ? item.month : + comparisonConfig?.type === 'year' ? item.year.toString() : null; + + const isCurrent = !comparisonConfig || periodVal === comparisonConfig.curr; + const isPrevious = comparisonConfig && periodVal === comparisonConfig.prev; + if (!map.has(asin)) { - map.set(asin, { ...item }); + // Initialize with empty record if it's new + const defaultRec: CombinedKPIs = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 }; + map.set(asin, { + curr: isCurrent ? { ...item } : { ...defaultRec }, + prev: isPrevious ? { ...item } : undefined + }); } else { - const existing = map.get(asin)!; - existing.salesTotal += item.salesTotal; - existing.unitsTotal += item.unitsTotal; - existing.salesAds += item.salesAds; - existing.unitsAds += item.unitsAds; - existing.cost += item.cost; - existing.clicks += item.clicks; - existing.impressions += item.impressions; - existing.conversions += item.conversions; - existing.salesOrganic += item.salesOrganic; + const entry = map.get(asin)!; + const target = isCurrent ? entry.curr : (isPrevious ? (entry.prev || (entry.prev = { ...item, salesTotal: 0, unitsTotal: 0, salesAds: 0, unitsAds: 0, cost: 0, clicks: 0, impressions: 0, conversions: 0, salesOrganic: 0 })) : null); + + if (target) { + target.salesTotal += item.salesTotal; + target.unitsTotal += item.unitsTotal; + target.salesAds += item.salesAds; + target.unitsAds += item.unitsAds; + target.cost += item.cost; + target.clicks += item.clicks; + target.impressions += item.impressions; + target.conversions += item.conversions; + target.salesOrganic += item.salesOrganic; + } } }); - return Array.from(map.values()).map(item => { - // Recalculate derived metrics for the aggregate - const salesTotal = item.salesTotal; - const adSales = item.salesAds; - const adCost = item.cost; - const adClicks = item.clicks; - const adImpressions = item.impressions; - const adConversions = item.conversions; + return Array.from(map.values()).map(({ curr, prev }) => { + const calcMetrics = (item: CombinedKPIs) => ({ + ...item, + acos: item.salesAds > 0 ? (item.cost / item.salesAds) * 100 : 0, + tacos: item.salesTotal > 0 ? (item.cost / item.salesTotal) * 100 : 0, + roas: item.cost > 0 ? item.salesAds / item.cost : 0, + ctr: item.impressions > 0 ? (item.clicks / item.impressions) * 100 : 0, + cpc: item.clicks > 0 ? item.cost / item.clicks : 0, + cvrUnits: item.clicks > 0 ? (item.conversions / item.clicks) * 100 : 0, + }); + + const currFinal = calcMetrics(curr); + const prevFinal = prev ? calcMetrics(prev) : undefined; return { - ...item, - acos: adSales > 0 ? (adCost / adSales) * 100 : 0, - tacos: salesTotal > 0 ? (adCost / salesTotal) * 100 : 0, - roas: adCost > 0 ? adSales / adCost : 0, - ctr: adImpressions > 0 ? (adClicks / adImpressions) * 100 : 0, - cpc: adClicks > 0 ? adCost / adClicks : 0, - cvrUnits: adClicks > 0 ? (adConversions / adClicks) * 100 : 0, + ...currFinal, + prevData: prevFinal }; }); - }, [data]); + }, [data, comparisonConfig]); - // 2. Global Totals for Summary Header + // 2. Global Totals const totals = useMemo(() => { - return aggregatedByAsin.reduce((acc, curr) => ({ + const sum = (items: any[]) => items.reduce((acc, curr) => ({ salesTotal: acc.salesTotal + curr.salesTotal, salesAds: acc.salesAds + curr.salesAds, salesOrganic: acc.salesOrganic + curr.salesOrganic, @@ -67,25 +112,25 @@ const AdsPerformance: React.FC = ({ data }) => { impressions: acc.impressions + curr.impressions, clicks: acc.clicks + curr.clicks, conversions: acc.conversions + curr.conversions, - }), { - salesTotal: 0, - salesAds: 0, - salesOrganic: 0, - cost: 0, - impressions: 0, - clicks: 0, - conversions: 0, - }); - }, [aggregatedByAsin]); + }), { salesTotal: 0, salesAds: 0, salesOrganic: 0, cost: 0, impressions: 0, clicks: 0, conversions: 0 }); - const globalMetrics = { - acos: totals.salesAds > 0 ? (totals.cost / totals.salesAds) * 100 : 0, - tacos: totals.salesTotal > 0 ? (totals.cost / totals.salesTotal) * 100 : 0, - roas: totals.cost > 0 ? totals.salesAds / totals.cost : 0, - ctr: totals.impressions > 0 ? (totals.clicks / totals.impressions) * 100 : 0, - cpc: totals.clicks > 0 ? totals.cost / totals.clicks : 0, - cvr: totals.clicks > 0 ? (totals.conversions / totals.clicks) * 100 : 0, - }; + const currTotals = sum(aggregatedByAsin); + const prevTotals = comparisonConfig ? sum(aggregatedByAsin.map(a => a.prevData).filter(Boolean)) : null; + + return { curr: currTotals, prev: prevTotals }; + }, [aggregatedByAsin, comparisonConfig]); + + const calculateDerived = (t: any) => ({ + acos: t.salesAds > 0 ? (t.cost / t.salesAds) * 100 : 0, + tacos: t.salesTotal > 0 ? (t.cost / t.salesTotal) * 100 : 0, + roas: t.cost > 0 ? t.salesAds / t.cost : 0, + ctr: t.impressions > 0 ? (t.clicks / t.impressions) * 100 : 0, + cpc: t.clicks > 0 ? t.cost / t.clicks : 0, + cvr: t.clicks > 0 ? (t.conversions / t.clicks) * 100 : 0, + }); + + const currMetrics = calculateDerived(totals.curr); + const prevMetrics = totals.prev ? calculateDerived(totals.prev) : null; // 3. Filter and Sort const filteredAndSorted = useMemo(() => { @@ -128,26 +173,29 @@ const AdsPerformance: React.FC = ({ data }) => {

Performance Overview

- {aggregatedByAsin.length} records processed • All • All Years + + {aggregatedByAsin.length} products • + {comparisonConfig ? ` Comparison: ${comparisonConfig.prev} vs ${comparisonConfig.curr}` : ' Direct Overview'} +
{/* Summary Grid */}
- - - - - - - - - - - - - + + + + + + + + + + + + +
{/* Table Section */} @@ -155,7 +203,9 @@ const AdsPerformance: React.FC = ({ data }) => {

Product Inventory

-

Displaying total accumulated data for selected filters.

+

+ {comparisonConfig ? `Comparing ${comparisonConfig.curr} vs ${comparisonConfig.prev}` : 'Displaying total data for selected filters.'} +

= ({ data }) => { - {filteredAndSorted.map((row) => ( + {filteredAndSorted.map((row: any) => (
@@ -198,19 +248,19 @@ const AdsPerformance: React.FC = ({ data }) => { {row.title}
- {formatNumber(row.salesTotal)} - {formatNumber(row.salesAds)} - {formatNumber(row.salesOrganic)} - {formatNumber(row.cost)} - {formatNumber(row.impressions)} - {formatNumber(row.clicks)} - {formatNumber(row.conversions)} - ${row.cpc.toFixed(2)} - {row.ctr.toFixed(2)}% - {row.cvrUnits.toFixed(2)}% - {row.acos.toFixed(2)}% - {row.roas.toFixed(2)} - {row.tacos.toFixed(2)}% + + + + + + + + + + + + + ))} @@ -221,12 +271,47 @@ const AdsPerformance: React.FC = ({ data }) => { ); }; -const SummaryCard = ({ label, value, color = "text-white" }: { label: string; value: string; color?: string }) => ( -
- {label} - {value} -
-); +const SummaryCard = ({ label, value, prevValue, color = "text-white", inverse = false }: { label: string; value: string; prevValue?: number; color?: string; inverse?: boolean }) => { + const growth = prevValue !== undefined && prevValue > 0 + ? ((parseFloat(value.replace(/[^0-9.-]+/g, "")) - prevValue) / prevValue) * 100 + : null; + + return ( +
+ {label} +
+ {value} + {growth !== null && ( + 0 ? !inverse : inverse) ? 'text-emerald-400' : 'text-rose-500'}`}> + {growth > 0 ? '↑' : '↓'} {Math.abs(growth).toFixed(1)}% + + )} +
+
+ ); +}; + +const TableCell = ({ value, prevValue, format, prefix = "", inverse = false, highlight = false }: { value: number; prevValue?: number; format: 'currency' | 'number' | 'percent'; prefix?: string; inverse?: boolean; highlight?: boolean }) => { + const growth = prevValue !== undefined && prevValue > 0 ? ((value - prevValue) / prevValue) * 100 : null; + + let displayValue = ""; + if (format === 'currency') displayValue = new Intl.NumberFormat('en-US').format(value); + else if (format === 'number') displayValue = prefix + value.toFixed(format === 'number' && prefix === "$" ? 2 : 0); + else if (format === 'percent') displayValue = value.toFixed(2) + "%"; + + return ( + +
+ {displayValue} + {growth !== null && ( + 0 ? !inverse : inverse) ? 'text-emerald-400' : 'text-rose-400'} opacity-80`}> + {growth > 0 ? '+' : ''}{growth.toFixed(1)}% + + )} +
+ + ); +} const SortableHeader = ({ label, sortKey, activeSort, onSort }: { label: string;