diff --git a/App.tsx b/App.tsx index 1b6c537..5259182 100644 --- a/App.tsx +++ b/App.tsx @@ -5,8 +5,8 @@ import Dashboard from './components/Dashboard'; import FilterBar from './components/FilterBar'; import AIChat from './components/AIChat'; import CrazeLogo from './components/CrazeLogo'; -import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV } from './services/dataProcessor'; -import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment } from './types'; +import { processCSV, processExcel, filterData, aggregateData, processAdsCSV, processAdsExcel, mergeSalesAndAdsData, processTrafficExcel, processStockExcel, filterAdsData, calculateForecastViewData, processVendorStockExcel, processUKInventoryExcel, calculateVelocityMap, getUniqueValues, processForecastExcel, processBuyBoxExcel, processVendorCSV, processBSRExcel } from './services/dataProcessor'; +import { SalesRecord, FilterState, AggregatedData, AdsRecord, TrafficRecord, ForecastRecord, ProductForecastData, ActiveExperiment, BSRRecord } from './types'; import { queryGemini } from './services/geminiService'; import { ChartIcon, TableIcon, UploadIcon, DownloadIcon, CloseIcon, TrendingIcon, MegaphoneIcon } from './components/Icons'; import { loadSalesData, saveSalesData, clearSalesData, loadAdsData, saveAdsData, clearAdsData } from './services/storage'; @@ -60,6 +60,7 @@ const App: React.FC = () => { const [cachedForecastRecords, setCachedForecastRecords] = useState([]); const [lastForecastFile, setLastForecastFile] = useState(null); const [buyBoxLostMap, setBuyBoxLostMap] = useState }>>(new Map()); + const [bsrData, setBsrData] = useState([]); // Experiments state const [experimentMap, setExperimentMap] = useState>(new Map()); @@ -248,6 +249,21 @@ const App: React.FC = () => { } }, []); + const handleBSRFetch = useCallback(async () => { + try { + console.log('[App] Fetching BSR data from /api/fetch-bsr...'); + const response = await fetch('/api/fetch-bsr'); + if (!response.ok) throw new Error(`Failed to fetch BSR data: ${response.status}`); + + const buffer = await response.arrayBuffer(); + const data = await processBSRExcel(buffer); + setBsrData(data); + console.log('[App] Successfully loaded BSR data:', data.length, 'records'); + } catch (error) { + console.error("Failed to fetch/parse BSR data", error); + } + }, []); + const handleExperimentsFetch = useCallback(async () => { try { const expMap = await getActiveExperiments(rawData); @@ -400,7 +416,8 @@ const App: React.FC = () => { handleStockFetch(), handleVendorStockFetch(), handleBuyBoxFetch(), - handleExperimentsFetch() + handleExperimentsFetch(), + handleBSRFetch() ]).catch(e => console.warn("Background fetch failed", e)); // 1f. Fetch Forecast data diff --git a/BSR.xlsx b/BSR.xlsx new file mode 100644 index 0000000..60c2930 Binary files /dev/null and b/BSR.xlsx differ diff --git a/api/fetch-bsr.ts b/api/fetch-bsr.ts new file mode 100644 index 0000000..22ad347 --- /dev/null +++ b/api/fetch-bsr.ts @@ -0,0 +1,27 @@ +import { promises as fs } from 'fs'; +import path from 'path'; +import type { VercelRequest, VercelResponse } from '@vercel/node'; + +export default async function handler(req: VercelRequest, res: VercelResponse) { + try { + const filePath = path.join(process.cwd(), 'BSR.xlsx'); + + try { + await fs.access(filePath); + } catch { + return res.status(404).json({ error: 'BSR data file not found' }); + } + + const fileBuffer = await fs.readFile(filePath); + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', 'attachment; filename="BSR.xlsx"'); + res.setHeader('Cache-Control', 'public, max-age=3600'); + + // Convert Buffer to ArrayBuffer before sending for fetch compatibility on client + res.send(fileBuffer); + } catch (error) { + console.error('Error serving BSR Excel file:', error); + res.status(500).json({ error: 'Failed to serve BSR file' }); + } +} diff --git a/components/VendorDataView.tsx b/components/VendorDataView.tsx index c8d5e83..b707e9b 100644 --- a/components/VendorDataView.tsx +++ b/components/VendorDataView.tsx @@ -1,8 +1,7 @@ - -import React, { useState, useEffect, useMemo, useCallback } from 'react'; -import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer, BarChart, Bar } from 'recharts'; +import React, { useState, useMemo } from 'react'; +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts'; import MultiSelectDropdown from './MultiSelectDropdown'; -import { fetchVendorData, fetchVendorFilterOptions, VendorDailyRow } from '../services/supabase'; +import { BSRRecord } from '../types'; const MARKET_COLORS: Record = { DE: '#3b82f6', @@ -12,174 +11,121 @@ const MARKET_COLORS: Record = { ES: '#f59e0b', }; -interface WeeklyBSR { +interface VendorDataViewProps { + bsrData: BSRRecord[]; +} + +interface WeeklyChartPoint { weekLabel: string; - [key: string]: number | string | null; // e.g. DE_bsr, UK_bsr + [key: string]: number | string | null; } -interface WeeklyRating { - weekLabel: string; - avg_rating: number | null; - num_reviews: number | null; -} - -interface WeeklyBuyBox { - weekLabel: string; - [key: string]: number | string | null; // e.g. DE_amazon_pct -} - -// Get ISO week from date string -function getISOWeek(dateStr: string): string { - const d = new Date(dateStr); - d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() + 3 - ((d.getDay() + 6) % 7)); - const week1 = new Date(d.getFullYear(), 0, 4); - const weekNum = 1 + Math.round(((d.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7); - return `${d.getFullYear()}-W${String(weekNum).padStart(2, '0')}`; -} - -function aggregateToWeekly(rows: VendorDailyRow[], markets: string[]) { - // Group by week - const byWeek = new Map(); - for (const row of rows) { - const wk = getISOWeek(row.date); - if (!byWeek.has(wk)) byWeek.set(wk, []); - byWeek.get(wk)!.push(row); - } - - const sortedWeeks = [...byWeek.keys()].sort(); - - // BSR: min rank per week per market (best rank) - const bsrData: WeeklyBSR[] = sortedWeeks.map(wk => { - const weekRows = byWeek.get(wk)!; - const point: WeeklyBSR = { weekLabel: wk.replace(/^\d{4}-/, '') }; - for (const m of markets) { - const marketRows = weekRows.filter(r => r.market === m && r.bsr_detail_rank != null); - if (marketRows.length > 0) { - // Average BSR across ASINs for that market+week - const avg = marketRows.reduce((s, r) => s + r.bsr_detail_rank!, 0) / marketRows.length; - point[`${m}_bsr`] = Math.round(avg); - } else { - point[`${m}_bsr`] = null; - } - } - return point; - }); - - // Ratings: average across all markets/ASINs per week - const ratingData: WeeklyRating[] = sortedWeeks.map(wk => { - const weekRows = byWeek.get(wk)!; - const withRating = weekRows.filter(r => r.avg_rating != null); - const withReviews = weekRows.filter(r => r.num_reviews != null); - return { - weekLabel: wk.replace(/^\d{4}-/, ''), - avg_rating: withRating.length > 0 - ? Math.round((withRating.reduce((s, r) => s + r.avg_rating!, 0) / withRating.length) * 10) / 10 - : null, - num_reviews: withReviews.length > 0 - ? Math.round(withReviews.reduce((s, r) => s + r.num_reviews!, 0) / withReviews.length) - : null, - }; - }); - - // Buy Box: % of rows where Amazon has buybox, per market per week - const buyBoxData: WeeklyBuyBox[] = sortedWeeks.map(wk => { - const weekRows = byWeek.get(wk)!; - const point: WeeklyBuyBox = { weekLabel: wk.replace(/^\d{4}-/, '') }; - for (const m of markets) { - const marketRows = weekRows.filter(r => r.market === m && r.amazon_has_buybox != null); - if (marketRows.length > 0) { - const amazonCount = marketRows.filter(r => r.amazon_has_buybox === true).length; - point[`${m}_pct`] = Math.round((amazonCount / marketRows.length) * 100); - } else { - point[`${m}_pct`] = null; - } - } - return point; - }); - - return { bsrData, ratingData, buyBoxData }; -} - -const VendorDataView: React.FC = () => { - const [vendorRows, setVendorRows] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - - // Filter options - const [availableMarkets, setAvailableMarkets] = useState([]); - const [availableTags, setAvailableTags] = useState([]); - const [availableYears, setAvailableYears] = useState([]); - - // Selected filters +const VendorDataView: React.FC = ({ bsrData }) => { + // Filters const [selectedMarkets, setSelectedMarkets] = useState([]); - const [selectedTags, setSelectedTags] = useState([]); - const [selectedYears, setSelectedYears] = useState([]); + const [selectedTopCats, setSelectedTopCats] = useState([]); + const [selectedDetailCats, setSelectedDetailCats] = useState([]); const [asinSearch, setAsinSearch] = useState(''); - const [dateFrom, setDateFrom] = useState(''); - const [dateTo, setDateTo] = useState(''); - // Load filter options on mount - useEffect(() => { - const loadFilters = async () => { - try { - const opts = await fetchVendorFilterOptions(); - setAvailableMarkets(opts.markets); - setAvailableTags(opts.tags); - // Extract years from date range - if (opts.dateRange) { - const minYear = new Date(opts.dateRange.min).getFullYear(); - const maxYear = new Date(opts.dateRange.max).getFullYear(); - const years: number[] = []; - for (let y = minYear; y <= maxYear; y++) years.push(y); - setAvailableYears(years); - } - } catch (e: any) { - console.error('Failed to load vendor filter options', e); - } + // Extract available filter options from the dataset + const { availableMarkets, availableTopCats, availableDetailCats } = useMemo(() => { + const markets = new Set(); + const topCats = new Set(); + const detailCats = new Set(); + + bsrData.forEach(r => { + if (r.market) markets.add(r.market); + if (r.topLevelName) topCats.add(r.topLevelName); + if (r.detailLevelName) detailCats.add(r.detailLevelName); + }); + + return { + availableMarkets: Array.from(markets).sort(), + availableTopCats: Array.from(topCats).sort(), + availableDetailCats: Array.from(detailCats).sort() }; - loadFilters(); - }, []); + }, [bsrData]); - // Fetch data when filters change - const loadData = useCallback(async () => { - setLoading(true); - setError(null); - try { - const data = await fetchVendorData({ - markets: selectedMarkets.length > 0 ? selectedMarkets : undefined, - tags: selectedTags.length > 0 ? selectedTags : undefined, - asins: asinSearch.trim() ? asinSearch.split(',').map(a => a.trim()).filter(Boolean) : undefined, - dateFrom: dateFrom || undefined, - dateTo: dateTo || undefined, - years: selectedYears.length > 0 ? selectedYears : undefined, - }); - setVendorRows(data); - } catch (e: any) { - setError(e.message); - } finally { - setLoading(false); + // Apply filters + const filteredData = useMemo(() => { + let result = bsrData; + + if (selectedMarkets.length > 0) { + result = result.filter(r => selectedMarkets.includes(r.market)); + } + if (selectedTopCats.length > 0) { + result = result.filter(r => r.topLevelName && selectedTopCats.includes(r.topLevelName)); + } + if (selectedDetailCats.length > 0) { + result = result.filter(r => r.detailLevelName && selectedDetailCats.includes(r.detailLevelName)); + } + if (asinSearch.trim()) { + const terms = asinSearch.toLowerCase().split(',').map(t => t.trim()).filter(Boolean); + result = result.filter(r => terms.some(t => r.asin.toLowerCase().includes(t))); } - }, [selectedMarkets, selectedTags, selectedYears, asinSearch, dateFrom, dateTo]); - useEffect(() => { - loadData(); - }, [loadData]); + return result; + }, [bsrData, selectedMarkets, selectedTopCats, selectedDetailCats, asinSearch]); - // Determine active markets from data + // Determine active markets in filtered data for series generation const activeMarkets = useMemo(() => { - const markets = [...new Set(vendorRows.map(r => r.market))].sort(); - return markets.length > 0 ? markets : availableMarkets; - }, [vendorRows, availableMarkets]); + const m = new Set(filteredData.map(r => r.market)); + return Array.from(m).sort(); + }, [filteredData]); - // Aggregate to weekly - const { bsrData, ratingData, buyBoxData } = useMemo( - () => aggregateToWeekly(vendorRows, activeMarkets), - [vendorRows, activeMarkets] - ); + // Aggregate Data for Charts + const { topBsrChartData, detailBsrChartData, ratingChartData } = useMemo(() => { + const byWeek = new Map(); + filteredData.forEach(r => { + if (!byWeek.has(r.week)) byWeek.set(r.week, []); + byWeek.get(r.week)!.push(r); + }); - // Empty state - if (!loading && vendorRows.length === 0 && !error) { + const sortedWeeks = Array.from(byWeek.keys()).sort((a, b) => a - b); + + const topBsrChartData: WeeklyChartPoint[] = []; + const detailBsrChartData: WeeklyChartPoint[] = []; + const ratingChartData: WeeklyChartPoint[] = []; + + sortedWeeks.forEach(week => { + const row = byWeek.get(week)!; + const weekLabel = `Week ${week}`; + + const topPoint: WeeklyChartPoint = { weekLabel }; + const detailPoint: WeeklyChartPoint = { weekLabel }; + const ratingPoint: WeeklyChartPoint = { weekLabel }; + + activeMarkets.forEach(m => { + const marketRows = row.filter(r => r.market === m); + + // Top BSR + const topBsrRows = marketRows.filter(r => r.topLevelBSR != null); + topPoint[`${m}_bsr`] = topBsrRows.length > 0 + ? Math.round(topBsrRows.reduce((sum, r) => sum + r.topLevelBSR!, 0) / topBsrRows.length) + : null; + + // Detail BSR + const detailBsrRows = marketRows.filter(r => r.detailLevelBSR != null); + detailPoint[`${m}_bsr`] = detailBsrRows.length > 0 + ? Math.round(detailBsrRows.reduce((sum, r) => sum + r.detailLevelBSR!, 0) / detailBsrRows.length) + : null; + + // Rating + const ratingRows = marketRows.filter(r => r.avgRating != null); + ratingPoint[`${m}_rating`] = ratingRows.length > 0 + ? Math.round((ratingRows.reduce((sum, r) => sum + r.avgRating!, 0) / ratingRows.length) * 10) / 10 + : null; + }); + + topBsrChartData.push(topPoint); + detailBsrChartData.push(detailPoint); + ratingChartData.push(ratingPoint); + }); + + return { topBsrChartData, detailBsrChartData, ratingChartData }; + }, [filteredData, activeMarkets]); + + if (bsrData.length === 0) { return (
@@ -187,10 +133,9 @@ const VendorDataView: React.FC = () => {
-

No Vendor Data Yet

+

No BSR Data Yet

- Upload a Vendor Central daily CSV to see BSR trends, ratings, and Buy Box data. - Use the data source settings (top-right) to upload. + Ensure BSR.xlsx is present and loading to see Top Level BSR, Detail Level BSR, and Average Rating trends.

); @@ -207,30 +152,16 @@ const VendorDataView: React.FC = () => { onChange={setSelectedMarkets} /> setSelectedYears(vals.map(v => parseInt(v)))} - /> - setDateFrom(e.target.value)} - className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" - placeholder="From date" - /> - setDateTo(e.target.value)} - className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" - placeholder="To date" + label="Detail Level Category" + options={availableDetailCats} + selected={selectedDetailCats} + onChange={setSelectedDetailCats} /> { onChange={e => setAsinSearch(e.target.value)} className="bg-slate-900 border border-slate-700 rounded-lg px-3 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 w-64" /> - {(selectedMarkets.length > 0 || selectedTags.length > 0 || selectedYears.length > 0 || dateFrom || dateTo || asinSearch) && ( + {(selectedMarkets.length > 0 || selectedTopCats.length > 0 || selectedDetailCats.length > 0 || asinSearch) && ( )} - {loading && ( -
-
- Loading... -
- )} - {error && Error: {error}} - {vendorRows.length.toLocaleString()} daily records + {filteredData.length.toLocaleString()} records filtered - {/* BSR Trend Chart */} + {/* Top Level BSR Trend Chart */}
-

BSR Detail Category Rank (Weekly Avg)

+

Top Level BSR Trend (Weekly Avg)

Lower rank = better position. Averaged across filtered ASINs per market.

- + @@ -294,63 +216,63 @@ const VendorDataView: React.FC = () => {
- {/* Ratings & Reviews Chart */} + {/* Detail Level BSR Trend Chart */}
-

Average Rating & Reviews (Weekly)

-

Averaged across all filtered ASINs and markets.

-
- - - - - - - - - - - - - - - - - - -
-
- - {/* Buy Box Chart */} -
-

Amazon Buy Box Ownership (Weekly %)

-

Percentage of daily records where Amazon holds the Buy Box, per market.

+

Detail Level BSR Trend (Weekly Avg)

+

Lower rank = better position. Averaged across filtered ASINs per market.

- + - `${v}%`} /> + `${value}%`} /> {activeMarkets.map(m => ( - ))} - + + +
+ + {/* Average Rating Chart */} +
+

Average Rating (Weekly Avg)

+

Averaged across filtered ASINs per market.

+ + + + + + + + {activeMarkets.map(m => ( + + ))} +
diff --git a/components/WeeklyGrid.tsx b/components/WeeklyGrid.tsx index a2adf45..668aa87 100644 --- a/components/WeeklyGrid.tsx +++ b/components/WeeklyGrid.tsx @@ -10,7 +10,6 @@ import { VendorStockBadge } from './VendorStockBadge'; import { BuyBoxWarningBadge } from './BuyBoxWarningBadge'; import { WarehouseIcon, AmazonSmileIcon, CoverageIcon } from './Icons'; import { ExcelFilter } from './ExcelFilter'; -import ExperimentTracker from './ExperimentTracker'; import { ExperimentBadge } from './ExperimentBadge'; import { ActiveExperiment } from '../types'; @@ -800,9 +799,6 @@ const WeeklyGrid: React.FC = ({ return (
- {/* Experiment Tracker */} - - {/* Toolbar: Search & Pagination */}
diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index 43ef769..b36f473 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -1,4 +1,4 @@ -import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData, VendorCSVRow, VendorDailyRow } from '../types'; +import { SalesRecord, AdsRecord, TrafficRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint, ForecastRecord, MonthlyForecastPoint, ProductForecastData, VendorCSVRow, VendorDailyRow, BSRRecord } from '../types'; import * as XLSX from 'xlsx'; import Papa from 'papaparse'; @@ -657,6 +657,60 @@ export const processTrafficExcel = async (fileOrBuffer: File | ArrayBuffer): Pro } }; +// --- BSR DATA PARSING --- + +export const processBSRExcel = async (fileOrBuffer: File | ArrayBuffer): Promise => { + try { + const arrayBuffer = fileOrBuffer instanceof File + ? await fileOrBuffer.arrayBuffer() + : fileOrBuffer; + const workbook = XLSX.read(arrayBuffer, { type: 'array' }); + const allData: BSRRecord[] = []; + + // Typically BSR is in the first sheet + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + const jsonData: any[] = XLSX.utils.sheet_to_json(worksheet, { defval: "" }); + + console.log(`Processing BSR sheet "${sheetName}": ${jsonData.length} rows`); + + for (let i = 0; i < jsonData.length; i++) { + const row = jsonData[i]; + + const weekRaw = getColumnValue(row, ['week', 'woche', 'semana']); + const marketRaw = getColumnValue(row, ['market', 'country', 'marketplace']); + const asinRaw = getColumnValue(row, ['asin']); + const topLevelBSRRaw = getColumnValue(row, ['Mean Weekly Top Level BSR']); + const topLevelNameRaw = getColumnValue(row, ['Top Level Category Name']); + const detailLevelBSRRaw = getColumnValue(row, ['Mean Weekly Detail Level BSR']); + const detailLevelNameRaw = getColumnValue(row, ['Detail Level Category Name']); + const avgRatingRaw = getColumnValue(row, ['Mean Weekly Average Rating']); + + if (!weekRaw || !marketRaw || !asinRaw) continue; + + const week = parseInt(String(weekRaw).match(/\\d+/)?.[0] || '0', 10); + if (isNaN(week) || week === 0) continue; + + allData.push({ + week, + market: String(marketRaw).trim(), + asin: String(asinRaw).trim(), + topLevelBSR: parseIntSafe(String(topLevelBSRRaw)), + topLevelName: topLevelNameRaw ? String(topLevelNameRaw).trim() : null, + detailLevelBSR: parseIntSafe(String(detailLevelBSRRaw)), + detailLevelName: detailLevelNameRaw ? String(detailLevelNameRaw).trim() : null, + avgRating: parseFloat(String(avgRatingRaw)) || null, + }); + } + + console.log(`Total BSR records loaded: ${allData.length}`); + return allData; + } catch (error) { + console.error("Error processing BSR Excel:", error); + throw error; + } +}; + // --- DATA MERGING --- export const mergeSalesAndAdsData = ( diff --git a/types.ts b/types.ts index 591334b..82bd006 100644 --- a/types.ts +++ b/types.ts @@ -280,6 +280,17 @@ export interface VendorCSVRow { 'Glance Views': string; } +export interface BSRRecord { + week: number; + market: string; + asin: string; + topLevelBSR: number | null; + topLevelName: string | null; + detailLevelBSR: number | null; + detailLevelName: string | null; + avgRating: number | null; +} + // Experiment Tracking Types export type ExperimentType = 'pricing' | 'advertising' | 'content' | 'promotion' | 'seo'; export type ExperimentStatus = 'planned' | 'active' | 'completed' | 'paused';