From 2eed94b94b36d133b3817b3c648e278b356290a5 Mon Sep 17 00:00:00 2001 From: Christian Vidal Wolf Date: Tue, 3 Mar 2026 13:07:59 +0100 Subject: [PATCH] fix(dataProcessor): parse DD/M/YY dates from column C in sell-out CSV Add support for European day-first date format (e.g. "23/2/26" = 23 Feb 2026) in the sell-out CSV pipeline so months and years are correctly extracted from column C of Amazon Sell Out 2023-2025.csv. - normalizeMonth: detect DD/MM/YY when first part > 12, return "Mon-YY" - mapRowToRecord: add 'C', 'Date', 'Fecha', 'DATA' to month column aliases - validateSellOutHeaders: accept date columns as substitute for YEAR + MONTH Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 130 +++++++-------- components/MarketGapReport.tsx | 279 --------------------------------- services/dataProcessor.ts | 33 +++- test-calc-2.js | 106 ------------- test-calc-es.js | 83 ---------- test-calc.js | 69 -------- test-csv.js | 66 -------- test-fetch.js | 22 --- test-supabase.js | 40 ----- vite.config.ts | 31 +++- 10 files changed, 112 insertions(+), 747 deletions(-) delete mode 100644 components/MarketGapReport.tsx delete mode 100644 test-calc-2.js delete mode 100644 test-calc-es.js delete mode 100644 test-calc.js delete mode 100644 test-csv.js delete mode 100644 test-fetch.js delete mode 100644 test-supabase.js diff --git a/CLAUDE.md b/CLAUDE.md index 44b8305..5b61dfe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,64 +12,24 @@ npm run preview # Preview production build No test or lint scripts are configured. ESLint config exists (`eslint.config.js`) but has no npm script. -## Environment +## Deployment -Requires Node.js >= 18. Set `GEMINI_API_KEY` in `.env.local` for the AI chat feature (Google Gemini API). - -## Project Structure +Project: `craze-analytix2` under Vercel team `christians-projects-dd62b5fc`. +```bash +vercel --prod --scope christians-projects-dd62b5fc ``` -├── App.tsx # Main app component, global state orchestrator -├── index.tsx # React entry point -├── index.html # HTML template (Tailwind CDN loaded here) -├── types.ts # All TypeScript interfaces -├── vite.config.ts # Vite config (port 3000, proxy routes, path alias) -├── vercel.json # Vercel deployment config (API rewrites, SPA routing) -├── firebase.json # Firebase hosting config (alternative deployment) -├── api/ # Vercel serverless functions -│ ├── ask-gemini.ts # Gemini AI chat proxy -│ ├── fetch-data.ts # Sales CSV from Dropbox -│ ├── fetch-ads.ts # Ads Excel from Dropbox -│ ├── fetch-traffic.ts # Traffic data -│ ├── fetch-stock.ts # Item availability -│ ├── fetch-paneu-stock.ts # PAN-EU vendor stock -│ ├── fetch-uk-inventory.ts# UK inventory -│ ├── fetch-buybox.ts # Buy Box tracker -│ ├── fetch-forecast.ts # Forecast data -│ └── fetch-vendor-stock.ts -├── components/ # React components -│ ├── Dashboard.tsx # KPI cards, charts, YoY comparisons -│ ├── DataGrid.tsx # Pivot table with Excel-style filters (~96KB) -│ ├── WeeklyGrid.tsx # Weekly time-series view -│ ├── TopMovers.tsx # Growth/decline analysis -│ ├── AdsPerformance.tsx # Ad spend, ROAS, ACOS metrics -│ ├── ForecastView.tsx # Product forecasts with seasonality -│ ├── FilterBar.tsx # Sticky filter controls -│ ├── AIChat.tsx # AI assistant sidebar -│ ├── FileUpload.tsx # Manual data upload modal -│ ├── ExcelFilter.tsx # Advanced column filtering UI -│ ├── MultiSelectDropdown.tsx # Reusable filter dropdown -│ ├── StockBadge.tsx # Stock status indicator -│ ├── VendorStockBadge.tsx # Vendor stock indicator -│ ├── BuyBoxWarningBadge.tsx # Buy Box loss warnings -│ ├── Top50Badge.tsx # Top 50 product badge -│ ├── InColumnStockFilter.tsx # In-column stock filter -│ ├── NumericColumnFilter.tsx # Numeric column filter -│ ├── CrazeLogo.tsx # Header branding -│ ├── ErrorBoundary.tsx # Error handling wrapper -│ └── Icons.tsx # SVG icon library -├── services/ # Business logic and data layer -│ ├── dataProcessor.ts # Core data engine (~2400 lines) -│ ├── storage.ts # IndexedDB + localStorage caching -│ ├── geminiService.ts # AI context builder + API calls -│ └── filterHelper.ts # Filter utilities -└── public/ # Static data files - ├── fc 26.xlsx # EU forecast - ├── fc UK 26.xlsx # UK forecast - ├── Item Availability.xlsx - ├── Buy_Box_tracker.xlsx - └── Vendor Stock.xlsx -``` + +The `.vercel/project.json` must point to `projectId: prj_TYI5xVvuZ0kPI5Ap8zmX7cqfjsCi` and `orgId: team_5AhzZHthpZINNw9vOrKxOINr`. + +## Environment Variables + +| Variable | Used in | Purpose | +|---|---|---| +| `GEMINI_API_KEY` | `.env.local` | AI chat (Google Gemini) | +| `SUPABASE_URL` | Vercel env / serverless | Supabase project URL | +| `SUPABASE_SERVICE_KEY` | `api/experiments.ts`, `api/upload-vendor-data.ts` | Server-side Supabase access | +| `SUPABASE_ANON_KEY` | `services/supabase.ts` | Client-side Supabase access | ## Architecture @@ -87,41 +47,59 @@ Dropbox (CSV/Excel files) Data is cached in IndexedDB via `services/storage.ts`. On load, cached data displays immediately while fresh data fetches in the background. +All data sources are fetched automatically from Dropbox — there is no manual file upload. + ### Key Files -- **App.tsx** — Main orchestrator. Holds all global state (rawData, adsData, trafficData, filters, etc.) and passes data/handlers as props to views. -- **services/dataProcessor.ts** (~2400 lines) — Core data engine. Handles CSV/Excel parsing, currency normalization (EU `1.234,56` and US `1,234.56` formats), Spanish/English month mapping, filtering (`filterData`, `filterAdsData`), aggregation (`aggregateData`), and pivot table generation (`pivotSalesData`). -- **types.ts** — All TypeScript interfaces: `SalesRecord`, `AdsRecord`, `TrafficRecord`, `ForecastRecord`, `FilterState`, `AggregatedData`, `PivotRow`, etc. -- **services/storage.ts** — IndexedDB + localStorage caching with schema versioning. -- **services/geminiService.ts** — Builds structured context from aggregated data and sends to Gemini API. +- **App.tsx** — Main orchestrator. Holds all global state and passes data/handlers as props to views. All data fetching (`handleDataFetch`, `handleAdsFetch`, `handleBSRFetch`, etc.) is initiated here. +- **services/dataProcessor.ts** (~2400 lines) — Core data engine. Handles CSV/Excel parsing, currency normalization (EU `1.234,56` and US `1,234.56` formats), Spanish/English month mapping, filtering (`filterData`, `filterAdsData`, `filterBsrData`), aggregation, and pivot table generation. +- **types.ts** — All TypeScript interfaces: `SalesRecord`, `AdsRecord`, `TrafficRecord`, `ForecastRecord`, `BSRRecord`, `FilterState`, `AggregatedData`, `PivotRow`, `Experiment`, etc. +- **services/storage.ts** — IndexedDB + localStorage caching with schema versioning. Increment `SCHEMA_VERSION` when changing cached data shapes. +- **services/experiments.ts** — CRUD operations for experiments via `/api/experiments` (Supabase-backed). Includes ASIN resolution logic for line-level experiments. +- **services/experimentAnalysis.ts** — Difference-in-Differences (DiD) statistical analysis engine. Uses ISO week boundaries (Monday start). -### Views (rendered conditionally by `view` state in App.tsx) +### Views -| View | Component | Purpose | -|------|-----------|---------| -| dashboard | Dashboard.tsx | KPI cards, charts, YoY comparisons | -| table | DataGrid.tsx | Pivot table with Excel-style column filters | -| weekly | WeeklyGrid.tsx | Weekly time-series breakdown | -| movers | TopMovers.tsx | Top growth/decline products | -| ads | AdsPerformance.tsx | Ad spend, ROAS, ACOS metrics | -| forecast | ForecastView.tsx | Product forecasts with velocity mapping | +| View key | Component | Purpose | +|----------|-----------|---------| +| `dashboard` | Dashboard.tsx | KPI cards, charts, YoY comparisons | +| `table` | DataGrid.tsx | Pivot table with Excel-style column filters | +| `weekly` | WeeklyGrid.tsx | Weekly time-series breakdown | +| `movers` | TopMovers.tsx | Top growth/decline products | +| `ads` | AdsPerformance.tsx | Ad spend, ROAS, ACOS metrics | +| `forecast` | ForecastView.tsx | Product forecasts with velocity mapping | +| `vendor` | VendorDataView.tsx | BSR trends, ratings per market. Shows product card (SKU/ASIN/title/BuyBox) when a single ASIN is filtered | +| `experiments` | ExperimentsView.tsx | A/B experiment tracking with DiD analysis and Bayesian verdicts | ### API Routes (`/api/`) -All serverless functions fetch data from Dropbox (direct download URLs with `dl=1`). Each returns the raw file content for client-side processing: -- `fetch-data.ts` (sales CSV), `fetch-ads.ts` (ads Excel), `fetch-traffic.ts`, `fetch-stock.ts`, `fetch-paneu-stock.ts`, `fetch-uk-inventory.ts`, `fetch-buybox.ts`, `fetch-forecast.ts` -- `ask-gemini.ts` — Proxies chat requests to Google Gemini API +All fetch routes proxy Dropbox direct-download URLs (`dl=1`) and return raw file content for client-side parsing: + +- `fetch-data.ts` — Sales CSV +- `fetch-ads.ts` — Ads Excel (weekly) +- `fetch-traffic.ts` — Traffic/Glance Views Excel +- `fetch-stock.ts` — Item availability +- `fetch-paneu-stock.ts` — PAN-EU vendor stock +- `fetch-uk-inventory.ts` — UK inventory +- `fetch-buybox.ts` — Buy Box tracker Excel +- `fetch-forecast.ts` — Forecast Excel +- `fetch-bsr.ts` — BSR/ratings Excel (feeds Vendor tab) +- `ask-gemini.ts` — Proxies AI chat to Gemini API +- `experiments.ts` — CRUD for experiments stored in Supabase +- `upload-vendor-data.ts` — Batch upsert of vendor rows to Supabase ### Important Constants (in dataProcessor.ts) - `PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES']` — Default country filter when no customer is selected -- `MONTH_ORDER`, `MONTH_MAP` — Month normalization including Spanish names (Enero→Jan, etc.) -- `parseCurrency()` — Handles both EU and US number formats -- Country mapping normalizes various spellings (e.g., "Germany", "Deutschland", "DE", "Alemania" → "Amazon DE") +- `parseCurrency()` — Handles both EU (`1.234,56`) and US (`1,234.56`) number formats +- Country mapping normalizes various spellings → `Amazon DE`, `Amazon UK`, etc. +- `MONTH_MAP` — Normalizes Spanish month names (Enero→Jan, etc.) ### Filtering Architecture -Filters flow from `FilterBar.tsx` → `App.tsx` state → `filterData()`/`filterAdsData()` in dataProcessor.ts. When no customer filter is selected, ads data defaults to PAN_EU_COUNTRIES only. Sales data and ads data have separate filter functions. +Filters flow: `FilterBar.tsx` → `App.tsx` state → `filterData()` / `filterAdsData()` / `filterBsrData()` in dataProcessor.ts. When no customer filter is selected, ads data defaults to PAN_EU_COUNTRIES only. Sales and ads data have separate filter functions. + +`globalAsinMetadata` (`Map`) is built from `rawData` in App.tsx and passed to views that need product metadata lookups. ### Path Alias diff --git a/components/MarketGapReport.tsx b/components/MarketGapReport.tsx deleted file mode 100644 index e2922a0..0000000 --- a/components/MarketGapReport.tsx +++ /dev/null @@ -1,279 +0,0 @@ -import React, { useState, useMemo, useCallback } from 'react'; -import * as XLSX from 'xlsx'; -import { SalesRecord } from '../types'; -import { DownloadIcon } from './Icons'; - -interface MarketGapReportProps { - data: SalesRecord[]; -} - -interface GapProduct { - rank: number; - asin: string; - title: string; - sku: string; - line: string; - deSellOut: number; - deUnits: number; -} - -const FlagDE = () => ( - - 🇩🇪 DE - -); - -const MarketGapReport: React.FC = ({ data }) => { - const availableYears = useMemo(() => { - const years = Array.from(new Set(data.map(r => r.year))).sort((a, b) => b - a); - return years; - }, [data]); - - const [selectedYear, setSelectedYear] = useState(null); - - const effectiveYear = selectedYear ?? availableYears[0] ?? new Date().getFullYear(); - - const top5 = useMemo((): GapProduct[] => { - if (data.length === 0) return []; - - // 1. Build set of ASINs ever sold in Amazon ES (all years) - const esAsins = new Set(); - data.forEach(r => { - if (r.customer?.toLowerCase().includes('amazon es')) { - esAsins.add(r.asin.trim().toUpperCase()); - } - }); - - // 2. Build metadata map (title, sku, line) from all years — prefer longest title - const metaMap = new Map(); - data.forEach(r => { - const asin = r.asin.trim().toUpperCase(); - const existing = metaMap.get(asin); - if (!existing || (r.title && r.title.length > (existing.title?.length || 0))) { - metaMap.set(asin, { title: r.title || r.articleName || asin, sku: r.sku || '', line: r.line || 'Unassigned' }); - } - }); - - // 3. Aggregate DE sales for the selected year - const deMap = new Map(); - data.forEach(r => { - if (r.year !== effectiveYear) return; - if (!r.customer?.toLowerCase().includes('amazon de')) return; - const asin = r.asin.trim().toUpperCase(); - const entry = deMap.get(asin) || { sellOut: 0, units: 0 }; - entry.sellOut += r.sellOut || 0; - entry.units += r.units || 0; - deMap.set(asin, entry); - }); - - // 4. Filter out ASINs sold in ES, sort desc by sellOut, top 5 - const results: GapProduct[] = []; - deMap.forEach((val, asin) => { - if (esAsins.has(asin)) return; - if (val.sellOut <= 0) return; - const meta = metaMap.get(asin) || { title: asin, sku: '', line: 'Unassigned' }; - results.push({ - rank: 0, - asin, - title: meta.title, - sku: meta.sku, - line: meta.line, - deSellOut: val.sellOut, - deUnits: val.units, - }); - }); - - results.sort((a, b) => b.deSellOut - a.deSellOut); - return results.slice(0, 5).map((r, i) => ({ ...r, rank: i + 1 })); - }, [data, effectiveYear]); - - const handleExport = useCallback(() => { - if (top5.length === 0) return; - const exportData = top5.map(p => ({ - Rank: p.rank, - ASIN: p.asin, - Title: p.title, - SKU: p.sku, - 'Product Line': p.line, - [`DE Sell Out ${effectiveYear} (€)`]: Number(p.deSellOut.toFixed(2)), - [`DE Units ${effectiveYear}`]: p.deUnits, - 'Amazon DE PDP': `https://www.amazon.de/dp/${p.asin}`, - })); - const ws = XLSX.utils.json_to_sheet(exportData); - const wb = XLSX.utils.book_new(); - XLSX.utils.book_append_sheet(wb, ws, 'DE-Only Top 5'); - XLSX.writeFile(wb, `DE_Only_Top5_${effectiveYear}.xlsx`); - }, [top5, effectiveYear]); - - const fmt = (n: number) => - `€${n.toLocaleString('de-DE', { minimumFractionDigits: 0, maximumFractionDigits: 0 })}`; - - return ( -
- - {/* Header Card */} -
-
-

- 🌍 Market Gap Report -

-

- Top 5 productos vendidos en 🇩🇪 Alemania que{' '} - nunca se han vendido en{' '} - 🇪🇸 España -

-
- -
- {/* Year selector */} -
- -
- {availableYears.map(y => ( - - ))} -
-
- - {/* Export */} - -
-
- - {/* Insight banner */} - {top5.length > 0 && ( -
- 💡 Oportunidad de expansión: Estos {top5.length} productos ya funcionan en DE y podrían tener potencial en el mercado español. -
- )} - - {/* Table */} -
-
-

- 📊 Top 5 — Solo Alemania ({effectiveYear}) -

- - Ranking por Sell-Out (€) - -
- -
- - - - - - - - - - - - - {top5.map((product) => ( - - {/* Rank */} - - - {/* Product details */} - - - {/* Line */} - - - {/* DE Sell Out */} - - - {/* DE Units */} - - - {/* Amazon PDP Link */} - - - ))} - - {top5.length === 0 && ( - - - - )} - -
#ProductoLíneaSell-Out DEUnits DELink
- - {product.rank} - - -
- - {product.title || 'Unknown Title'} - -
- {product.sku && ( - SKU: {product.sku} - )} - ASIN: {product.asin} -
-
-
- - {product.line} - - -
- {fmt(product.deSellOut)} - -
-
- {product.deUnits.toLocaleString('de-DE')} uds. - - - Amazon.de ↗ - -
- No se encontraron productos exclusivos de DE en {effectiveYear}.
- Verifica que los datos contienen registros de Amazon DE y Amazon ES. -
-
- - {/* Footer note */} - {top5.length > 0 && ( -
- * Se excluyen todos los ASINs con cualquier venta histórica en Amazon ES, independientemente del año seleccionado. -
- )} -
-
- ); -}; - -export default MarketGapReport; diff --git a/services/dataProcessor.ts b/services/dataProcessor.ts index a7b1d48..c529371 100644 --- a/services/dataProcessor.ts +++ b/services/dataProcessor.ts @@ -193,9 +193,30 @@ const normalizeMonth = (rawMonth: string): string => { } // 2. Handle numeric months "01", "1", "01-2023" - // If it's a full date string like "2023-04-01" or "01/04/2023" + // If it's a full date string like "2023-04-01", "01/04/2023", or "23/2/26" (DD/M/YY) if (m.includes('/') || m.includes('-')) { - // Try parsing standard date + // Handle DD/M/YY or DD/MM/YY (European day-first format, e.g. "23/2/26" = 23 Feb 2026) + const parts = m.split(m.includes('/') ? '/' : '-'); + if (parts.length === 3) { + const [a, b, c] = parts.map(p => parseInt(p, 10)); + if (!isNaN(a) && !isNaN(b) && !isNaN(c)) { + // If first part > 12: definitely day-first → DD/MM/YY + if (a > 12 && b >= 1 && b <= 12) { + const yearShort = c < 100 ? String(c).padStart(2, '0') : String(c).slice(2); + const result = `${MONTH_ORDER[b - 1]}-${yearShort}`; + monthCache[rawMonth] = result; + return result; + } + // YYYY/MM/DD or YYYY-MM-DD (ISO-like, year is 4 digits in first position) + if (a > 31 && b >= 1 && b <= 12) { + const yearShort = String(a).slice(2); + const result = `${MONTH_ORDER[b - 1]}-${yearShort}`; + monthCache[rawMonth] = result; + return result; + } + } + } + // Try parsing standard date as fallback const date = new Date(m); if (!isNaN(date.getTime())) { const monthIdx = date.getMonth(); @@ -289,8 +310,10 @@ const isAllowedCustomer = (customer: string): boolean => { export const validateSellOutHeaders = (headers: string[]) => { const normHeaders = headers.map(h => String(h).trim().toLowerCase()); - const hasYear = normHeaders.includes('year'); - const hasTime = normHeaders.includes('month') || normHeaders.includes('week'); + // A date column (e.g. column named "C", "Date", "Fecha") can provide both year and month + const hasDateCol = ['c', 'date', 'fecha', 'data'].some(d => normHeaders.includes(d)); + const hasYear = normHeaders.includes('year') || hasDateCol; + const hasTime = normHeaders.includes('month') || normHeaders.includes('week') || hasDateCol; const hasCustomerRef = normHeaders.includes('customer reference') || normHeaders.includes('asin'); const hasEan = normHeaders.includes('ean'); const hasUnits = normHeaders.includes('units'); @@ -315,7 +338,7 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => { const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']); // Sanitize year string before parsing (remove commas/dots e.g. "2,023") let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0; - const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period']); + const monthStr = getColumnValue(row, ['MONTH', 'Month', 'Period', 'C', 'Date', 'DATE', 'Fecha', 'FECHA', 'Data', 'DATA']); const month = normalizeMonth(monthStr); // BACKFILL YEAR if missing but present in Month (e.g. "Apr-23") diff --git a/test-calc-2.js b/test-calc-2.js deleted file mode 100644 index 246717e..0000000 --- a/test-calc-2.js +++ /dev/null @@ -1,106 +0,0 @@ -// Script to deeply test the calculation logic -import fs from 'fs'; - -const mockExperiment = { - id: 'test-1', - name: 'Test Exp', - type: 'advertising', - status: 'active', - start_date: '2026-01-01', - end_date: '2026-01-14', - asins: ['LINE:HAIRCARE'], - primary_metric: 'revenue' -}; - -const mockSalesData = [ - // Before experiment (Baseline) - { year: 2025, week: 52, asin: 'ASIN1', line: 'HAIRCARE', salesTotal: 500, unitsTotal: 50, cost: 50, glanceViews: 100 }, - { year: 2025, week: 52, asin: 'ASIN2', line: 'HAIRCARE', salesTotal: 200, unitsTotal: 20, cost: 20, glanceViews: 50 }, - - // During experiment - { year: 2026, week: 1, asin: 'ASIN1', line: 'HAIRCARE', salesTotal: 1000, unitsTotal: 100, cost: 100, glanceViews: 200 }, - { year: 2026, week: 2, asin: 'ASIN2', line: 'HAIRCARE', salesTotal: 400, unitsTotal: 40, cost: 40, glanceViews: 100 }, - - // Outside date range - { year: 2026, week: 3, asin: 'ASIN1', line: 'HAIRCARE', salesTotal: 100, unitsTotal: 10, cost: 10, glanceViews: 20 }, - - // Different product line - { year: 2026, week: 1, asin: 'ASIN3', line: 'SKINCARE', salesTotal: 1000, unitsTotal: 100, cost: 100, glanceViews: 200 } -]; - -const getExperimentAsins = (experimentAsins, salesData) => { - const explicitAsins = new Set(); - const lines = new Set(); - - (experimentAsins || []).forEach(a => { - const val = (a || '').trim().toUpperCase(); - if (val.startsWith('LINE:')) { - lines.add(val.substring(5).trim()); - } else if (val) { - explicitAsins.add(val); - } - }); - - const asinSet = new Set(explicitAsins); - if (lines.size > 0 && salesData) { - salesData.forEach(r => { - const line = (r.line || '').trim().toUpperCase(); - if (line && lines.has(line)) { - if (r.asin) asinSet.add(r.asin.toUpperCase()); - } - }); - } - - return asinSet; -}; - -const parseLocalDate = (dateStr) => { - if (!dateStr) return new Date(); - const [y, m, d] = dateStr.split('T')[0].split('-'); - return new Date(Number(y), Number(m) - 1, Number(d)); -}; - - -const calculate = (experiment, salesData) => { - const startDate = parseLocalDate(experiment.start_date); - const endDate = experiment.end_date ? parseLocalDate(experiment.end_date) : new Date(); - - console.log('Parsed start date:', startDate); - console.log('Parsed end date:', endDate); - - const durationMs = endDate.getTime() - startDate.getTime(); - const baselineStart = new Date(startDate.getTime() - durationMs); - const baselineEnd = startDate; - - console.log('Parsed baseline start:', baselineStart); - console.log('Parsed baseline end:', baselineEnd); - - const asinSet = getExperimentAsins(experiment.asins, salesData); - console.log('Resolved ASINs:', Array.from(asinSet)); - - const baselineData = salesData.filter(r => { - const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7); - const inRange = recordDate >= baselineStart && recordDate < baselineEnd; - const isAsin = asinSet.has(r.asin.toUpperCase()); - if (isAsin) console.log(`[Baseline] Week ${r.year}-${r.week} date: ${recordDate} (inRange: ${inRange})`); - return isAsin && inRange; - }); - - const experimentData = salesData.filter(r => { - const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7); - const inRange = recordDate >= startDate && recordDate <= endDate; - const isAsin = asinSet.has(r.asin.toUpperCase()); - if (isAsin) console.log(`[Experiment] Week ${r.year}-${r.week} date: ${recordDate} (inRange: ${inRange})`); - return isAsin && inRange; - }); - - console.log('Baseline records matched:', baselineData.length); - console.log('Experiment records matched:', experimentData.length); - - const baseline_units = baselineData.reduce((sum, r) => sum + r.unitsTotal, 0); - const experiment_units = experimentData.reduce((sum, r) => sum + r.unitsTotal, 0); - - console.log('Units baseline:', baseline_units, 'Units experiment:', experiment_units); -}; - -calculate(mockExperiment, mockSalesData); diff --git a/test-calc-es.js b/test-calc-es.js deleted file mode 100644 index 671a58d..0000000 --- a/test-calc-es.js +++ /dev/null @@ -1,83 +0,0 @@ -import fs from 'fs'; - -const experiment = { - start_date: "2026-01-18", - end_date: null, - asins: ["LINE:LEGENDS"], - marketplace: "ES" -}; - -const salesData = [ - { year: 2026, week: 7, asin: "B0CHW338VR", line: "LEGENDS", unitsTotal: 4, salesTotal: 24.40, marketplace: "ES" }, - { year: 2026, week: 7, asin: "B0CHW338VR", line: "LEGENDS", unitsTotal: 4, salesTotal: 24.40, marketplace: "GB" } -]; - -const getExperimentAsins = (experimentAsins, salesData) => { - const explicitAsins = new Set(); - const lines = new Set(); - - (experimentAsins || []).forEach(a => { - const val = (a || '').trim().toUpperCase(); - if (val.startsWith('LINE:')) { - lines.add(val.substring(5).trim()); - } else if (val) { - explicitAsins.add(val); - } - }); - - const asinSet = new Set(explicitAsins); - if (lines.size > 0 && salesData) { - salesData.forEach(r => { - const line = (r.line || '').trim().toUpperCase(); - if (line && lines.has(line)) { - if (r.asin) asinSet.add(r.asin.toUpperCase()); - } - }); - } - - return asinSet; -}; - -const parseLocalDate = (dateStr) => { - if (!dateStr) return new Date(); - const [y, m, d] = dateStr.split('T')[0].split('-'); - return new Date(Number(y), Number(m) - 1, Number(d)); -}; - - -const calculate = (experiment, salesData) => { - const startDate = parseLocalDate(experiment.start_date); - const endDate = experiment.end_date ? parseLocalDate(experiment.end_date) : new Date(); - - console.log('Parsed start date:', startDate); - console.log('Parsed end date:', endDate); - - const durationMs = endDate.getTime() - startDate.getTime(); - const baselineStart = new Date(startDate.getTime() - durationMs); - const baselineEnd = startDate; - - console.log('Parsed baseline start:', baselineStart); - console.log('Parsed baseline end:', baselineEnd); - - const asinSet = getExperimentAsins(experiment.asins, salesData); - console.log('Resolved ASINs:', Array.from(asinSet)); - - const baselineData = salesData.filter(r => { - const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7); - const inRange = recordDate >= baselineStart && recordDate < baselineEnd; - const isAsin = asinSet.has(r.asin.toUpperCase()); - return isAsin && inRange && (experiment.marketplace === 'All' || r.marketplace === experiment.marketplace); - }); - - const experimentData = salesData.filter(r => { - const recordDate = new Date(r.year, 0, 1 + (r.week - 1) * 7); - const inRange = recordDate >= startDate && recordDate <= endDate; - const isAsin = asinSet.has(r.asin.toUpperCase()); - return isAsin && inRange && (experiment.marketplace === 'All' || r.marketplace === experiment.marketplace); - }); - - console.log('Baseline records matched:', baselineData.length); - console.log('Experiment records matched:', experimentData.length); -}; - -calculate(experiment, salesData); diff --git a/test-calc.js b/test-calc.js deleted file mode 100644 index 16b1ca0..0000000 --- a/test-calc.js +++ /dev/null @@ -1,69 +0,0 @@ -import fs from 'fs'; -import path from 'path'; - -// read dummy data or mock -const mockSalesData = [ - { - asin: 'B08F2J8S1Y', - line: 'LEGENDS', - year: 2026, - week: 1, - unitsTotal: 10, - salesTotal: 100, - }, - { - asin: 'B08F2J8S1Y', - line: 'LEGENDS', - year: 2026, - week: 2, - unitsTotal: 15, - salesTotal: 150, - } -]; - -const experimentAsins = ['LINE:LEGENDS']; - -const lines = new Set(); -const explicitAsins = new Set(); -experimentAsins.forEach(a => { - const val = (a || '').trim().toUpperCase(); - if (val.startsWith('LINE:')) { - lines.add(val.substring(5).trim()); - } else if (val) { - explicitAsins.add(val); - } -}); - -const asinSet = new Set(explicitAsins); -if (lines.size > 0 && mockSalesData) { - mockSalesData.forEach(r => { - const line = (r.line || '').trim().toUpperCase(); - if (line && lines.has(line)) { - if (r.asin) asinSet.add(r.asin.toUpperCase()); - } - }); -} - -console.log('Resolved ASINs:', Array.from(asinSet)); - -// Date test -const startDateStr = '2026-01-01'; -const endDateStr = '2026-01-14'; - -const startDate = new Date(startDateStr); -const endDate = new Date(endDateStr); - -console.log('Start Date UTC:', startDate.toISOString(), 'Local:', startDate.toString()); -console.log('End Date UTC:', endDate.toISOString(), 'Local:', endDate.toString()); - -const recordDate1 = new Date(2026, 0, 1); // week 1 -const recordDate2 = new Date(2026, 0, 8); // week 2 -const recordDate3 = new Date(2026, 0, 15); // week 3 - -console.log('Week 1 Record Date Local:', recordDate1.toString()); -console.log('Week 1 included in experiment?', recordDate1 >= startDate && recordDate1 <= endDate); -console.log('Week 2 Record Date Local:', recordDate2.toString()); -console.log('Week 2 included in experiment?', recordDate2 >= startDate && recordDate2 <= endDate); -console.log('Week 3 Record Date Local:', recordDate3.toString()); -console.log('Week 3 included in experiment?', recordDate3 >= startDate && recordDate3 <= endDate); - diff --git a/test-csv.js b/test-csv.js deleted file mode 100644 index 20e8ca0..0000000 --- a/test-csv.js +++ /dev/null @@ -1,66 +0,0 @@ -import fs from 'fs'; -import https from 'https'; - -const url = "https://www.dropbox.com/scl/fi/b9zxn4z5i7sxwfakk5g5y/Amazon-Sell-Out-2023-2025.csv?rlkey=uoto6v0mm99py8nszy8ldtez8&dl=1"; - -https.get(url, (res) => { - if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) { - https.get(res.headers.location, (res2) => { - processData(res2); - }); - } else { - processData(res); - } -}).on('error', (e) => { - console.error(e); -}); - -function processData(stream) { - let data = ''; - stream.on('data', chunk => { - data += chunk.toString('utf8'); - }); - stream.on('end', () => { - analyze(data); - }); -} - -function analyze(data) { - const lines = data.split('\n'); - console.log("Total Lines in CSV:", lines.length); - - let maxYear = 0; - let maxWeek = 0; - let minYear = 2030; - let minWeek = 52; - let legendsRows = 0; - let legendsMaxYear = 0; - let legendsMaxWeek = 0; - - for (let i = 1; i < lines.length; i++) { - const row = lines[i].split(','); - if (row.length < 5) continue; - const year = parseInt(row[3]); // Based on app processor logic it's mostly col 3 or 1 - const week = parseInt(row[4]); // Based on app processor - - // Use regex fallback if parsing fails - const matchedYear = parseInt(row.find(r => r.startsWith('202')) || '0'); - const matchedWeek = parseInt(row.find(r => r.match(/^[0-9]{1,2}$/)) || '0'); - - const y = year || matchedYear || 0; - const w = week || matchedWeek || 0; - - if (y > maxYear) { maxYear = y; maxWeek = w; } - else if (y === maxYear && w > maxWeek) { maxWeek = w; } - - if (y < minYear && y > 2000) { minYear = y; minWeek = w; } - - if (lines[i].toLowerCase().includes('legends')) { - legendsRows++; - if (y > legendsMaxYear) { legendsMaxYear = y; legendsMaxWeek = w; } - else if (y === legendsMaxYear && w > legendsMaxWeek) { legendsMaxWeek = w; } - } - } - console.log(`Global -> Min Date: Year ${minYear}, Week ${minWeek} | Max Date: Year ${maxYear}, Week ${maxWeek}`); - console.log(`LEGENDS stats -> Total rows: ${legendsRows}, Max Date: Year ${legendsMaxYear}, Week ${legendsMaxWeek}`); -} diff --git a/test-fetch.js b/test-fetch.js deleted file mode 100644 index 5194f5a..0000000 --- a/test-fetch.js +++ /dev/null @@ -1,22 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; -import dotenv from 'dotenv'; -dotenv.config({ path: '.env.local' }); - -const supabase = createClient( - process.env.SUPABASE_URL || '', - process.env.SUPABASE_SERVICE_KEY || process.env.SUPABASE_ANON_KEY || '' -); - -async function testFetch() { - const { data: exps, error: err1 } = await supabase.from('experiments').select('*').limit(10); - if (err1 || !exps?.length) { - console.log('No experiments found or error:', err1); - return; - } - - for (const exp of exps) { - console.log(`[${exp.id}] Name: ${exp.name} | Start: ${exp.start_date} | ASINs: ${JSON.stringify(exp.asins)}`); - } -} - -testFetch(); diff --git a/test-supabase.js b/test-supabase.js deleted file mode 100644 index 47e4118..0000000 --- a/test-supabase.js +++ /dev/null @@ -1,40 +0,0 @@ -import { createClient } from '@supabase/supabase-js'; -import dotenv from 'dotenv'; -dotenv.config({ path: '.env.local' }); - -const supabase = createClient( - process.env.VITE_SUPABASE_URL || '', - process.env.VITE_SUPABASE_SERVICE_KEY || process.env.VITE_SUPABASE_ANON_KEY || '' -); - -async function testUpdate() { - // Grab the first active experiment - const { data: exps, error: err1 } = await supabase.from('experiments').select('*').limit(1); - if (err1 || !exps?.length) { - console.log('No experiments found or error:', err1); - return; - } - - const exp = exps[0]; - console.log('Testing update on:', exp.id, exp.name); - - // Try to push a dummy update just for the new columns - const { data, error } = await supabase - .from('experiments') - .update({ - experiment_acos: 15.5, - experiment_cvr: 10.2, - updated_at: new Date().toISOString() - }) - .eq('id', exp.id) - .select() - .single(); - - if (error) { - console.error('Supabase Error:', error); - } else { - console.log('Success!', data); - } -} - -testUpdate(); diff --git a/vite.config.ts b/vite.config.ts index 1c24da8..e44da55 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,4 +1,5 @@ import path from 'path'; +import fs from 'fs'; import { defineConfig, loadEnv } from 'vite'; import react from '@vitejs/plugin-react'; @@ -53,7 +54,35 @@ export default defineConfig(({ mode }) => { } } }, - plugins: [react()], + // To support fetching local 'BSR.xlsx' in Vite dev mode, we configure a custom middleware + // since we use a local file instead of a dropbox link for BSR: + plugins: [ + react(), + { + name: 'serve-local-bsr', + configureServer(server) { + server.middlewares.use('/api/fetch-bsr', (req, res, next) => { + const filePath = path.join(process.cwd(), 'BSR.xlsx'); + + try { + if (fs.existsSync(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'); + const fileStream = fs.createReadStream(filePath); + fileStream.pipe(res); + } else { + res.statusCode = 404; + res.end(JSON.stringify({ error: 'BSR data file not found' })); + } + } catch (err) { + res.statusCode = 500; + res.end(JSON.stringify({ error: 'Failed' })); + } + }); + } + } + ], define: { // loadEnv reads .env files; process.env has Vercel/system env vars at build time 'process.env.API_KEY': JSON.stringify(