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 <noreply@anthropic.com>
This commit is contained in:
Christian Vidal Wolf
2026-03-03 13:07:59 +01:00
co-authored by Claude Sonnet 4.6
parent ad1695d360
commit 2eed94b94b
10 changed files with 112 additions and 747 deletions
+54 -76
View File
@@ -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<string, {sku, title, line}>`) is built from `rawData` in App.tsx and passed to views that need product metadata lookups.
### Path Alias
-279
View File
@@ -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 = () => (
<span className="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-bold bg-yellow-500/10 border border-yellow-500/30 text-yellow-400">
🇩🇪 DE
</span>
);
const MarketGapReport: React.FC<MarketGapReportProps> = ({ 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<number | null>(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<string>();
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<string, { title: string; sku: string; line: string }>();
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<string, { sellOut: number; units: number }>();
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 (
<div className="max-w-5xl mx-auto pb-24 px-4 animate-fade-in space-y-6">
{/* Header Card */}
<div className="bg-surface border border-border rounded-xl p-6 shadow-lg flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<div>
<h2 className="text-2xl font-bold text-indigo-400 flex items-center gap-2">
🌍 Market Gap Report
</h2>
<p className="text-sm text-slate-400 mt-1">
Top 5 productos vendidos en <span className="text-yellow-400 font-semibold">🇩🇪 Alemania</span> que{' '}
<span className="text-rose-400 font-semibold">nunca se han vendido</span> en{' '}
<span className="text-red-400 font-semibold">🇪🇸 España</span>
</p>
</div>
<div className="flex items-center gap-3 flex-wrap">
{/* Year selector */}
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase font-semibold tracking-wider">Año:</label>
<div className="flex bg-slate-900 rounded-lg p-0.5 border border-slate-800">
{availableYears.map(y => (
<button
key={y}
onClick={() => setSelectedYear(y)}
className={`px-3 py-1.5 rounded-md text-sm font-medium transition-all ${effectiveYear === y
? 'bg-indigo-600 text-white shadow'
: 'text-slate-400 hover:text-white'
}`}
>
{y}
</button>
))}
</div>
</div>
{/* Export */}
<button
onClick={handleExport}
disabled={top5.length === 0}
className="flex items-center gap-2 px-4 py-2 bg-slate-800 hover:bg-slate-700 text-slate-300 rounded-lg text-sm font-medium border border-slate-700 transition-colors disabled:opacity-40"
>
<DownloadIcon />
Exportar Excel
</button>
</div>
</div>
{/* Insight banner */}
{top5.length > 0 && (
<div className="bg-indigo-500/5 border border-indigo-500/20 rounded-xl px-5 py-3 text-sm text-indigo-300">
💡 <span className="font-semibold">Oportunidad de expansión:</span> Estos {top5.length} productos ya funcionan en DE y podrían tener potencial en el mercado español.
</div>
)}
{/* Table */}
<div className="bg-surface border border-border rounded-xl shadow-lg overflow-hidden">
<div className="px-6 py-4 border-b border-border bg-slate-900/50 flex justify-between items-center">
<h3 className="text-lg font-bold text-indigo-300 flex items-center gap-2">
📊 Top 5 Solo Alemania ({effectiveYear})
</h3>
<span className="text-xs text-slate-500 uppercase font-semibold tracking-wider">
Ranking por Sell-Out ()
</span>
</div>
<div className="overflow-x-auto">
<table className="w-full text-left text-sm border-collapse">
<thead>
<tr className="bg-slate-950 text-slate-400 uppercase text-xs font-semibold tracking-wider">
<th className="px-5 py-3 border-b border-border w-12 text-center">#</th>
<th className="px-5 py-3 border-b border-border">Producto</th>
<th className="px-5 py-3 border-b border-border">Línea</th>
<th className="px-5 py-3 border-b border-border text-right">Sell-Out DE</th>
<th className="px-5 py-3 border-b border-border text-right">Units DE</th>
<th className="px-5 py-3 border-b border-border text-center">Link</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{top5.map((product) => (
<tr key={product.asin} className="hover:bg-slate-800/50 transition-colors group">
{/* Rank */}
<td className="px-5 py-4 text-center">
<span className={`inline-flex items-center justify-center w-8 h-8 rounded-full font-black text-sm ${product.rank === 1
? 'bg-yellow-500/20 text-yellow-400 border border-yellow-500/40'
: product.rank === 2
? 'bg-slate-400/10 text-slate-300 border border-slate-500/30'
: product.rank === 3
? 'bg-orange-500/10 text-orange-400 border border-orange-500/30'
: 'bg-slate-800 text-slate-400 border border-slate-700'
}`}>
{product.rank}
</span>
</td>
{/* Product details */}
<td className="px-5 py-4">
<div className="flex flex-col gap-0.5">
<span className="text-white font-medium leading-snug max-w-sm truncate" title={product.title}>
{product.title || 'Unknown Title'}
</span>
<div className="flex items-center gap-2 mt-0.5">
{product.sku && (
<span className="text-xs text-slate-500 font-mono">SKU: {product.sku}</span>
)}
<span className="text-xs text-slate-600 font-mono">ASIN: {product.asin}</span>
</div>
</div>
</td>
{/* Line */}
<td className="px-5 py-4">
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-800 text-slate-300 border border-slate-700">
{product.line}
</span>
</td>
{/* DE Sell Out */}
<td className="px-5 py-4 text-right">
<div className="flex flex-col items-end gap-0.5">
<span className="text-emerald-400 font-bold text-base">{fmt(product.deSellOut)}</span>
<FlagDE />
</div>
</td>
{/* DE Units */}
<td className="px-5 py-4 text-right text-slate-300 font-medium">
{product.deUnits.toLocaleString('de-DE')} uds.
</td>
{/* Amazon PDP Link */}
<td className="px-5 py-4 text-center">
<a
href={`https://www.amazon.de/dp/${product.asin}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 px-3 py-1.5 bg-orange-500/10 hover:bg-orange-500/20 border border-orange-500/30 text-orange-400 text-xs font-medium rounded-lg transition-colors"
title={`Ver ${product.asin} en Amazon.de`}
>
Amazon.de
</a>
</td>
</tr>
))}
{top5.length === 0 && (
<tr>
<td colSpan={6} className="px-6 py-16 text-center text-slate-500 italic">
No se encontraron productos exclusivos de DE en {effectiveYear}.<br />
<span className="text-xs mt-1 block">Verifica que los datos contienen registros de Amazon DE y Amazon ES.</span>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Footer note */}
{top5.length > 0 && (
<div className="px-6 py-3 bg-slate-900/40 border-t border-border text-xs text-slate-500">
* Se excluyen todos los ASINs con <span className="text-rose-400 font-medium">cualquier venta histórica</span> en Amazon ES, independientemente del año seleccionado.
</div>
)}
</div>
</div>
);
};
export default MarketGapReport;
+28 -5
View File
@@ -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")
-106
View File
@@ -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);
-83
View File
@@ -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);
-69
View File
@@ -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);
-66
View File
@@ -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}`);
}
-22
View File
@@ -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();
-40
View File
@@ -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();
+30 -1
View File
@@ -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(