feat: integrate Glance Views (GV) from Traffic Weekly.xlsx

- types.ts: Added TrafficRecord interface and glanceViews field to CombinedKPIs
- dataProcessor.ts: Added processTrafficExcel parser for Traffic Weekly.xlsx
- dataProcessor.ts: Updated mergeSalesAndAdsData to accept and merge traffic data
- App.tsx: Added trafficData state and handleTrafficUpload handler
- FileUpload.tsx: Added Traffic file upload section for GV data
- AdsPerformance.tsx: Added GV and CVR(GV) summary cards
- AdsPerformance.tsx: Added GV column to product table
This commit is contained in:
Christian Vidal Wolf
2026-01-23 09:07:38 +01:00
parent 473174f7e6
commit affae9e994
6 changed files with 150 additions and 11 deletions
+23 -5
View File
@@ -5,8 +5,8 @@ import Dashboard from './components/Dashboard';
import FilterBar from './components/FilterBar'; import FilterBar from './components/FilterBar';
import AIChat from './components/AIChat'; import AIChat from './components/AIChat';
import CrazeLogo from './components/CrazeLogo'; import CrazeLogo from './components/CrazeLogo';
import { SalesRecord, FilterState, AggregatedData, AdsRecord } from './types'; // Imported AdsRecord import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord } from './types';
import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, mergeSalesAndAdsData } from './services/dataProcessor'; import { processCSV, filterData, filterAdsData, aggregateData, getUniqueValues, processAdsCSV, processAdsExcel, processTrafficExcel, mergeSalesAndAdsData } from './services/dataProcessor';
import { queryGemini } from './services/geminiService'; import { queryGemini } from './services/geminiService';
import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons';
import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage'; import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage';
@@ -38,7 +38,8 @@ const PERMANENT_ADS_URL = "https://www.dropbox.com/scl/fi/wng8tep7awhvzd65amwad/
const App: React.FC = () => { const App: React.FC = () => {
const [rawData, setRawData] = useState<SalesRecord[]>([]); const [rawData, setRawData] = useState<SalesRecord[]>([]);
const [adsData, setAdsData] = useState<AdsRecord[]>([]); // New Ads State const [adsData, setAdsData] = useState<AdsRecord[]>([]);
const [trafficData, setTrafficData] = useState<TrafficRecord[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [syncing, setSyncing] = useState(false); const [syncing, setSyncing] = useState(false);
const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads'>('dashboard'); // Added 'weekly' view const [view, setView] = useState<'dashboard' | 'table' | 'weekly' | 'movers' | 'ads'>('dashboard'); // Added 'weekly' view
@@ -228,6 +229,22 @@ const App: React.FC = () => {
} }
}; };
// Handle uploaded Traffic file (Manual)
const handleTrafficUpload = async (file: File) => {
setSyncing(true);
try {
const data = await processTrafficExcel(file);
setTrafficData(data);
console.log("Traffic loaded:", data.length, "records from", file.name);
setIsDataModalOpen(false);
} catch (error: any) {
console.error("Failed to parse Traffic file", error);
alert(`Error parsing Traffic file: ${error.message || 'Unknown error'}. Please check the format.`);
} finally {
setSyncing(false);
}
};
// 2. Schedule Auto-Refresh (Background) // 2. Schedule Auto-Refresh (Background)
useEffect(() => { useEffect(() => {
const checkAndRefresh = () => { const checkAndRefresh = () => {
@@ -322,8 +339,8 @@ const App: React.FC = () => {
// Combine Sales & Ads Data dynamically based on current filters // Combine Sales & Ads Data dynamically based on current filters
const combinedAdsData = useMemo(() => { const combinedAdsData = useMemo(() => {
return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata); return mergeSalesAndAdsData(filteredData, filteredAdsData, globalAsinMetadata, trafficData);
}, [filteredData, filteredAdsData, globalAsinMetadata]); }, [filteredData, filteredAdsData, globalAsinMetadata, trafficData]);
// Derive Context Data (Product Line Context when drilling down) // Derive Context Data (Product Line Context when drilling down)
const contextAggregatedData = useMemo(() => { const contextAggregatedData = useMemo(() => {
@@ -527,6 +544,7 @@ const App: React.FC = () => {
<FileUpload <FileUpload
onSalesUpload={handleSalesUpload} onSalesUpload={handleSalesUpload}
onAdsUpload={handleAdsUpload} onAdsUpload={handleAdsUpload}
onTrafficUpload={handleTrafficUpload}
onUrlSubmit={handleDataFetch} onUrlSubmit={handleDataFetch}
isLoading={syncing} isLoading={syncing}
activeUrl={activeUrl} activeUrl={activeUrl}
Binary file not shown.
+8 -1
View File
@@ -77,6 +77,7 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters }) => {
target.impressions += item.impressions; target.impressions += item.impressions;
target.conversions += item.conversions; target.conversions += item.conversions;
target.salesOrganic += item.salesOrganic; target.salesOrganic += item.salesOrganic;
target.glanceViews = (target.glanceViews || 0) + (item.glanceViews || 0);
} }
} }
}); });
@@ -112,7 +113,8 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters }) => {
impressions: acc.impressions + curr.impressions, impressions: acc.impressions + curr.impressions,
clicks: acc.clicks + curr.clicks, clicks: acc.clicks + curr.clicks,
conversions: acc.conversions + curr.conversions, conversions: acc.conversions + curr.conversions,
}), { salesTotal: 0, salesAds: 0, salesOrganic: 0, cost: 0, impressions: 0, clicks: 0, conversions: 0 }); glanceViews: acc.glanceViews + (curr.glanceViews || 0),
}), { salesTotal: 0, salesAds: 0, salesOrganic: 0, cost: 0, impressions: 0, clicks: 0, conversions: 0, glanceViews: 0 });
const currTotals = sum(aggregatedByAsin); const currTotals = sum(aggregatedByAsin);
const prevTotals = comparisonConfig ? sum(aggregatedByAsin.map(a => a.prevData).filter(Boolean)) : null; const prevTotals = comparisonConfig ? sum(aggregatedByAsin.map(a => a.prevData).filter(Boolean)) : null;
@@ -127,6 +129,7 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters }) => {
ctr: t.impressions > 0 ? (t.clicks / t.impressions) * 100 : 0, ctr: t.impressions > 0 ? (t.clicks / t.impressions) * 100 : 0,
cpc: t.clicks > 0 ? t.cost / t.clicks : 0, cpc: t.clicks > 0 ? t.cost / t.clicks : 0,
cvr: t.clicks > 0 ? (t.conversions / t.clicks) * 100 : 0, cvr: t.clicks > 0 ? (t.conversions / t.clicks) * 100 : 0,
cvrGv: t.glanceViews > 0 ? (t.conversions / t.glanceViews) * 100 : 0,
}); });
const currMetrics = calculateDerived(totals.curr); const currMetrics = calculateDerived(totals.curr);
@@ -196,6 +199,8 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters }) => {
<SummaryCard label="ACOS" value={currMetrics.acos} prevValue={prevMetrics?.acos} format="percent" inverse /> <SummaryCard label="ACOS" value={currMetrics.acos} prevValue={prevMetrics?.acos} format="percent" inverse />
<SummaryCard label="ROAS" value={currMetrics.roas} prevValue={prevMetrics?.roas} format="number" decimals={2} color="text-blue-300" /> <SummaryCard label="ROAS" value={currMetrics.roas} prevValue={prevMetrics?.roas} format="number" decimals={2} color="text-blue-300" />
<SummaryCard label="TACOS" value={currMetrics.tacos} prevValue={prevMetrics?.tacos} format="percent" inverse /> <SummaryCard label="TACOS" value={currMetrics.tacos} prevValue={prevMetrics?.tacos} format="percent" inverse />
<SummaryCard label="GV" value={totals.curr.glanceViews} prevValue={totals.prev?.glanceViews} format="number" color="text-teal-400" />
<SummaryCard label="CVR (GV)" value={currMetrics.cvrGv} prevValue={prevMetrics?.cvrGv} format="percent" color="text-teal-300" />
</div> </div>
{/* Table Section */} {/* Table Section */}
@@ -236,6 +241,7 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters }) => {
<SortableHeader label="ACOS" sortKey="acos" activeSort={sortConfig} onSort={handleSort} /> <SortableHeader label="ACOS" sortKey="acos" activeSort={sortConfig} onSort={handleSort} />
<SortableHeader label="ROAS" sortKey="roas" activeSort={sortConfig} onSort={handleSort} /> <SortableHeader label="ROAS" sortKey="roas" activeSort={sortConfig} onSort={handleSort} />
<SortableHeader label="TACOS" sortKey="tacos" activeSort={sortConfig} onSort={handleSort} /> <SortableHeader label="TACOS" sortKey="tacos" activeSort={sortConfig} onSort={handleSort} />
<SortableHeader label="GV" sortKey="glanceViews" activeSort={sortConfig} onSort={handleSort} />
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/[0.03]"> <tbody className="divide-y divide-white/[0.03]">
@@ -261,6 +267,7 @@ const AdsPerformance: React.FC<AdsPerformanceProps> = ({ data, filters }) => {
<TableCell value={row.acos} prevValue={row.prevData?.acos} format="percent" inverse /> <TableCell value={row.acos} prevValue={row.prevData?.acos} format="percent" inverse />
<TableCell value={row.roas} prevValue={row.prevData?.roas} format="number" highlight /> <TableCell value={row.roas} prevValue={row.prevData?.roas} format="number" highlight />
<TableCell value={row.tacos} prevValue={row.prevData?.tacos} format="percent" inverse /> <TableCell value={row.tacos} prevValue={row.prevData?.tacos} format="percent" inverse />
<TableCell value={row.glanceViews || 0} prevValue={row.prevData?.glanceViews} format="number" />
</tr> </tr>
))} ))}
</tbody> </tbody>
+34 -1
View File
@@ -5,7 +5,8 @@ import { UploadIcon, MegaphoneIcon } from './Icons';
interface FileUploadProps { interface FileUploadProps {
onSalesUpload: (file: File) => void; onSalesUpload: (file: File) => void;
onAdsUpload: (file: File) => void; onAdsUpload: (file: File) => void;
onUrlSubmit: () => void; // Changed: no longer takes URL parameter onTrafficUpload?: (file: File) => void;
onUrlSubmit: () => void;
isLoading: boolean; isLoading: boolean;
activeUrl?: string | null; activeUrl?: string | null;
onDisconnect?: () => void; onDisconnect?: () => void;
@@ -15,6 +16,7 @@ interface FileUploadProps {
const FileUpload: React.FC<FileUploadProps> = ({ const FileUpload: React.FC<FileUploadProps> = ({
onSalesUpload, onSalesUpload,
onAdsUpload, onAdsUpload,
onTrafficUpload,
onUrlSubmit, onUrlSubmit,
isLoading, isLoading,
activeUrl, activeUrl,
@@ -35,6 +37,12 @@ const FileUpload: React.FC<FileUploadProps> = ({
} }
}; };
const handleTrafficChange = (e: ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0 && onTrafficUpload) {
onTrafficUpload(e.target.files[0]);
}
};
const handleUrlSubmit = (e: React.FormEvent) => { const handleUrlSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (url.trim()) { if (url.trim()) {
@@ -128,6 +136,31 @@ const FileUpload: React.FC<FileUploadProps> = ({
/> />
</label> </label>
</div> </div>
{onTrafficUpload && (
<div className="rounded-xl border border-dashed border-slate-700 bg-slate-900/50 hover:bg-slate-900 transition-colors group h-full col-span-2">
<label htmlFor="traffic-upload" className="cursor-pointer flex flex-col items-center justify-center gap-3 p-6 h-full">
<div className="p-3 bg-teal-500/10 rounded-full text-teal-400 group-hover:scale-110 transition-transform">
<svg className="w-6 h-6" fill="none" viewBox="0 0 24 24" strokeWidth={1.5} stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" d="M2.036 12.322a1.012 1.012 0 0 1 0-.639C3.423 7.51 7.36 4.5 12 4.5c4.638 0 8.573 3.007 9.963 7.178.07.207.07.431 0 .639C20.577 16.49 16.64 19.5 12 19.5c-4.638 0-8.573-3.007-9.963-7.178Z" />
<path strokeLinecap="round" strokeLinejoin="round" d="M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z" />
</svg>
</div>
<div className="text-center">
<h3 className="font-semibold text-slate-200">Upload Traffic Excel</h3>
<p className="text-[10px] text-slate-500 mt-1">Glance Views (GV) Data</p>
</div>
<input
id="traffic-upload"
type="file"
accept=".xlsx,.xls"
onChange={handleTrafficChange}
disabled={isLoading}
className="hidden"
/>
</label>
</div>
)}
</div> </div>
</div> </div>
); );
+76 -4
View File
@@ -1,4 +1,4 @@
import { SalesRecord, AdsRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types'; import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
import Papa from 'papaparse'; import Papa from 'papaparse';
@@ -439,17 +439,87 @@ export const processAdsExcel = async (fileOrBuffer: File | ArrayBuffer): Promise
} }
}; };
// --- TRAFFIC DATA PARSING ---
export const processTrafficExcel = async (fileOrBuffer: File | ArrayBuffer): Promise<TrafficRecord[]> => {
try {
const arrayBuffer = fileOrBuffer instanceof File
? await fileOrBuffer.arrayBuffer()
: fileOrBuffer;
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
const allData: TrafficRecord[] = [];
// Process first sheet only (Traffic Weekly.xlsx typically has one sheet)
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const jsonData: any[][] = XLSX.utils.sheet_to_json(worksheet, { header: 1, defval: "" });
console.log(`Processing Traffic sheet "${sheetName}": ${jsonData.length} rows`);
// Column mapping for Traffic Weekly.xlsx:
// A (0): Year
// B (1): Week
// C (2): ASIN
// F (5): Country
// G (6): Glance Views (GV)
// Skip header row (index 0), process data rows
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
if (!row || row.length < 7) continue;
const yearRaw = row[0];
const weekRaw = row[1];
const asin = row[2];
const countryRaw = row[5];
const gvRaw = row[6];
// Skip if missing essential data
if (!asin || yearRaw === undefined || weekRaw === undefined || !countryRaw) continue;
const year = parseInt(String(yearRaw));
const weekNum = parseInt(String(weekRaw));
if (isNaN(year) || year < 2020 || year > 2100) continue;
if (isNaN(weekNum) || weekNum < 1 || weekNum > 53) continue;
allData.push({
country: mapCountryToMarketplace(String(countryRaw)),
year,
week: weekNum,
asin: String(asin).trim().toUpperCase(),
glanceViews: parseUnits(String(gvRaw)),
});
}
console.log(`Total Traffic records loaded: ${allData.length}`);
return allData;
} catch (error) {
console.error("Error processing Traffic Excel:", error);
throw error;
}
};
// --- DATA MERGING --- // --- DATA MERGING ---
export const mergeSalesAndAdsData = ( export const mergeSalesAndAdsData = (
salesData: SalesRecord[], salesData: SalesRecord[],
adsData: AdsRecord[], adsData: AdsRecord[],
asinMetadataMap?: Map<string, { sku: string; title: string; line: string }> asinMetadataMap?: Map<string, { sku: string; title: string; line: string }>,
trafficData?: TrafficRecord[]
): CombinedKPIs[] => { ): CombinedKPIs[] => {
// Key for both sales and ads: ASIN|Customer|Year|Week // Key for both sales and ads: ASIN|Customer|Year|Week
const createKey = (asin: string, customer: string, year: number, week: number) => const createKey = (asin: string, customer: string, year: number, week: number) =>
`${asin.trim().toUpperCase()}|${customer.trim().toUpperCase()}|${year}|${week}`; `${asin.trim().toUpperCase()}|${customer.trim().toUpperCase()}|${year}|${week}`;
// Build traffic lookup map
const trafficMap = new Map<string, number>();
if (trafficData) {
trafficData.forEach(t => {
const key = createKey(t.asin, t.country, t.year, t.week);
trafficMap.set(key, (trafficMap.get(key) || 0) + t.glanceViews);
});
}
// 1. Initialize metadata lookup map with provided global map if available, otherwise build from current sales // 1. Initialize metadata lookup map with provided global map if available, otherwise build from current sales
const asinMetadata = asinMetadataMap || new Map<string, { sku: string; title: string; line: string }>(); const asinMetadata = asinMetadataMap || new Map<string, { sku: string; title: string; line: string }>();
@@ -579,7 +649,8 @@ export const mergeSalesAndAdsData = (
roas, roas,
ctr, ctr,
cpc, cpc,
cvrUnits cvrUnits,
glanceViews: trafficMap.get(key) || 0
}); });
}); });
@@ -617,7 +688,8 @@ export const mergeSalesAndAdsData = (
roas: ad.cost > 0 ? ad.attributedSales30d / ad.cost : 0, roas: ad.cost > 0 ? ad.attributedSales30d / ad.cost : 0,
ctr: ad.impressions > 0 ? (ad.clicks / ad.impressions) * 100 : 0, ctr: ad.impressions > 0 ? (ad.clicks / ad.impressions) * 100 : 0,
cpc: ad.clicks > 0 ? ad.cost / ad.clicks : 0, cpc: ad.clicks > 0 ? ad.cost / ad.clicks : 0,
cvrUnits: ad.clicks > 0 ? (ad.attributedUnits30d / ad.clicks) * 100 : 0 cvrUnits: ad.clicks > 0 ? (ad.attributedUnits30d / ad.clicks) * 100 : 0,
glanceViews: trafficMap.get(key) || 0
}); });
} }
}); });
+9
View File
@@ -150,6 +150,14 @@ export interface AdsRecord {
attributedSales30d: number; attributedSales30d: number;
} }
export interface TrafficRecord {
country: string; // Marketplace (DE, UK, etc.)
year: number;
week: number;
asin: string;
glanceViews: number; // Page views
}
export interface CombinedKPIs { export interface CombinedKPIs {
id: string; id: string;
marketplace: string; marketplace: string;
@@ -184,4 +192,5 @@ export interface CombinedKPIs {
ctr: number; ctr: number;
cpc: number; cpc: number;
cvrUnits: number; cvrUnits: number;
glanceViews: number; // Traffic / page views
} }