mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 11:05:23 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
ad1695d360
commit
2eed94b94b
@@ -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.
|
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: `craze-analytix2` under Vercel team `christians-projects-dd62b5fc`.
|
||||||
|
|
||||||
## Project Structure
|
|
||||||
|
|
||||||
|
```bash
|
||||||
|
vercel --prod --scope christians-projects-dd62b5fc
|
||||||
```
|
```
|
||||||
├── App.tsx # Main app component, global state orchestrator
|
|
||||||
├── index.tsx # React entry point
|
The `.vercel/project.json` must point to `projectId: prj_TYI5xVvuZ0kPI5Ap8zmX7cqfjsCi` and `orgId: team_5AhzZHthpZINNw9vOrKxOINr`.
|
||||||
├── index.html # HTML template (Tailwind CDN loaded here)
|
|
||||||
├── types.ts # All TypeScript interfaces
|
## Environment Variables
|
||||||
├── vite.config.ts # Vite config (port 3000, proxy routes, path alias)
|
|
||||||
├── vercel.json # Vercel deployment config (API rewrites, SPA routing)
|
| Variable | Used in | Purpose |
|
||||||
├── firebase.json # Firebase hosting config (alternative deployment)
|
|---|---|---|
|
||||||
├── api/ # Vercel serverless functions
|
| `GEMINI_API_KEY` | `.env.local` | AI chat (Google Gemini) |
|
||||||
│ ├── ask-gemini.ts # Gemini AI chat proxy
|
| `SUPABASE_URL` | Vercel env / serverless | Supabase project URL |
|
||||||
│ ├── fetch-data.ts # Sales CSV from Dropbox
|
| `SUPABASE_SERVICE_KEY` | `api/experiments.ts`, `api/upload-vendor-data.ts` | Server-side Supabase access |
|
||||||
│ ├── fetch-ads.ts # Ads Excel from Dropbox
|
| `SUPABASE_ANON_KEY` | `services/supabase.ts` | Client-side Supabase access |
|
||||||
│ ├── 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
|
|
||||||
```
|
|
||||||
|
|
||||||
## Architecture
|
## 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.
|
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
|
### Key Files
|
||||||
|
|
||||||
- **App.tsx** — Main orchestrator. Holds all global state (rawData, adsData, trafficData, filters, etc.) and passes data/handlers as props to views.
|
- **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`), aggregation (`aggregateData`), and pivot table generation (`pivotSalesData`).
|
- **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`, `FilterState`, `AggregatedData`, `PivotRow`, etc.
|
- **types.ts** — All TypeScript interfaces: `SalesRecord`, `AdsRecord`, `TrafficRecord`, `ForecastRecord`, `BSRRecord`, `FilterState`, `AggregatedData`, `PivotRow`, `Experiment`, etc.
|
||||||
- **services/storage.ts** — IndexedDB + localStorage caching with schema versioning.
|
- **services/storage.ts** — IndexedDB + localStorage caching with schema versioning. Increment `SCHEMA_VERSION` when changing cached data shapes.
|
||||||
- **services/geminiService.ts** — Builds structured context from aggregated data and sends to Gemini API.
|
- **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 |
|
| View key | Component | Purpose |
|
||||||
|------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| dashboard | Dashboard.tsx | KPI cards, charts, YoY comparisons |
|
| `dashboard` | Dashboard.tsx | KPI cards, charts, YoY comparisons |
|
||||||
| table | DataGrid.tsx | Pivot table with Excel-style column filters |
|
| `table` | DataGrid.tsx | Pivot table with Excel-style column filters |
|
||||||
| weekly | WeeklyGrid.tsx | Weekly time-series breakdown |
|
| `weekly` | WeeklyGrid.tsx | Weekly time-series breakdown |
|
||||||
| movers | TopMovers.tsx | Top growth/decline products |
|
| `movers` | TopMovers.tsx | Top growth/decline products |
|
||||||
| ads | AdsPerformance.tsx | Ad spend, ROAS, ACOS metrics |
|
| `ads` | AdsPerformance.tsx | Ad spend, ROAS, ACOS metrics |
|
||||||
| forecast | ForecastView.tsx | Product forecasts with velocity mapping |
|
| `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/`)
|
### 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:
|
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), `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
|
- `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)
|
### Important Constants (in dataProcessor.ts)
|
||||||
|
|
||||||
- `PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES']` — Default country filter when no customer is selected
|
- `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 (`1.234,56`) and US (`1,234.56`) number formats
|
||||||
- `parseCurrency()` — Handles both EU and US number formats
|
- Country mapping normalizes various spellings → `Amazon DE`, `Amazon UK`, etc.
|
||||||
- Country mapping normalizes various spellings (e.g., "Germany", "Deutschland", "DE", "Alemania" → "Amazon DE")
|
- `MONTH_MAP` — Normalizes Spanish month names (Enero→Jan, etc.)
|
||||||
|
|
||||||
### Filtering Architecture
|
### 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
|
### Path Alias
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
|
||||||
@@ -193,9 +193,30 @@ const normalizeMonth = (rawMonth: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 2. Handle numeric months "01", "1", "01-2023"
|
// 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('-')) {
|
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);
|
const date = new Date(m);
|
||||||
if (!isNaN(date.getTime())) {
|
if (!isNaN(date.getTime())) {
|
||||||
const monthIdx = date.getMonth();
|
const monthIdx = date.getMonth();
|
||||||
@@ -289,8 +310,10 @@ const isAllowedCustomer = (customer: string): boolean => {
|
|||||||
export const validateSellOutHeaders = (headers: string[]) => {
|
export const validateSellOutHeaders = (headers: string[]) => {
|
||||||
const normHeaders = headers.map(h => String(h).trim().toLowerCase());
|
const normHeaders = headers.map(h => String(h).trim().toLowerCase());
|
||||||
|
|
||||||
const hasYear = normHeaders.includes('year');
|
// A date column (e.g. column named "C", "Date", "Fecha") can provide both year and month
|
||||||
const hasTime = normHeaders.includes('month') || normHeaders.includes('week');
|
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 hasCustomerRef = normHeaders.includes('customer reference') || normHeaders.includes('asin');
|
||||||
const hasEan = normHeaders.includes('ean');
|
const hasEan = normHeaders.includes('ean');
|
||||||
const hasUnits = normHeaders.includes('units');
|
const hasUnits = normHeaders.includes('units');
|
||||||
@@ -315,7 +338,7 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => {
|
|||||||
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
|
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
|
||||||
// Sanitize year string before parsing (remove commas/dots e.g. "2,023")
|
// Sanitize year string before parsing (remove commas/dots e.g. "2,023")
|
||||||
let year = parseInt(yearStr.replace(/[,.]/g, '')) || 0;
|
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);
|
const month = normalizeMonth(monthStr);
|
||||||
|
|
||||||
// BACKFILL YEAR if missing but present in Month (e.g. "Apr-23")
|
// BACKFILL YEAR if missing but present in Month (e.g. "Apr-23")
|
||||||
|
|||||||
-106
@@ -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);
|
|
||||||
@@ -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);
|
|
||||||
@@ -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
@@ -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}`);
|
|
||||||
}
|
|
||||||
@@ -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();
|
|
||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import fs from 'fs';
|
||||||
import { defineConfig, loadEnv } from 'vite';
|
import { defineConfig, loadEnv } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
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: {
|
define: {
|
||||||
// loadEnv reads .env files; process.env has Vercel/system env vars at build time
|
// loadEnv reads .env files; process.env has Vercel/system env vars at build time
|
||||||
'process.env.API_KEY': JSON.stringify(
|
'process.env.API_KEY': JSON.stringify(
|
||||||
|
|||||||
Reference in New Issue
Block a user