mirror of
https://github.com/christianvidalwolf-prog/CrazeAnalytix.git
synced 2026-08-03 12:45:23 +02:00
feat: add units/revenue toggle to Weekly Sales and improve traffic data parsing
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
---
|
||||
name: crazeanalytix-standards
|
||||
description: Coding patterns and standards for the CrazeAnalytix project — an Amazon seller analytics dashboard built with React 19, TypeScript, Vite, and Tailwind CSS. Use this skill when creating new components, API routes, data processors, or modifying existing code in the CrazeAnalytix codebase to ensure consistency with established conventions.
|
||||
---
|
||||
|
||||
# CrazeAnalytix Development Standards
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
Single-page React 19 app with centralized state in `App.tsx`, Vercel serverless API routes, and client-side data processing. Data flows: Dropbox → `/api/*` routes → `dataProcessor.ts` → App state → View components.
|
||||
|
||||
## Component Patterns
|
||||
|
||||
### Structure
|
||||
|
||||
```tsx
|
||||
interface MyComponentProps {
|
||||
data: SalesRecord[];
|
||||
filters: FilterState;
|
||||
onFilterChange: (key: keyof FilterState, value: string[]) => void;
|
||||
}
|
||||
|
||||
const MyComponent: React.FC<MyComponentProps> = ({ data, filters, onFilterChange }) => {
|
||||
const computed = useMemo(() => aggregateData(data), [data]);
|
||||
|
||||
const handleChange = useCallback((value: string) => {
|
||||
onFilterChange('customer', [value]);
|
||||
}, [onFilterChange]);
|
||||
|
||||
return <div className="bg-slate-900 rounded-xl p-4">...</div>;
|
||||
};
|
||||
|
||||
export default MyComponent;
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Define `Props` interface above component
|
||||
- Use `React.FC<Props>` typing
|
||||
- Wrap expensive computations in `useMemo`
|
||||
- Wrap event handlers in `useCallback`
|
||||
- Use `React.memo()` for row/item renderers in lists and tables
|
||||
- Lazy-load view components: `const DataGrid = lazy(() => import('./components/DataGrid'))`
|
||||
|
||||
### State Management
|
||||
|
||||
All global state lives in `App.tsx` via `useState` hooks. No Redux. Props drilling to children.
|
||||
|
||||
```tsx
|
||||
const [rawData, setRawData] = useState<SalesRecord[]>([]);
|
||||
const [adsData, setAdsData] = useState<AdsRecord[]>([]);
|
||||
const [stockMap, setStockMap] = useState<Map<string, number>>(new Map());
|
||||
const [filters, setFilters] = useState<FilterState>({ customer: [], year: [], ... });
|
||||
```
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
| Context | Convention | Example |
|
||||
|---------|-----------|---------|
|
||||
| Components | PascalCase `.tsx` | `Dashboard.tsx`, `FilterBar.tsx` |
|
||||
| Services | camelCase `.ts` | `dataProcessor.ts`, `geminiService.ts` |
|
||||
| API routes | kebab-case `.ts` | `fetch-ads.ts`, `ask-gemini.ts` |
|
||||
| Process functions | `process<Format>` | `processCSV`, `processAdsExcel` |
|
||||
| Event handlers | `handle<Action>` | `handleDataFetch`, `handleFilterChange` |
|
||||
| Calculators | `calculate<What>` | `calculateVelocityMap`, `calculateLineMovers` |
|
||||
| State setters | `set<Noun>` | `setRawData`, `setFilters` |
|
||||
| Map variables | `<noun>Map` | `stockMap`, `vendorStockMap`, `buyBoxLostMap` |
|
||||
| Constants | `UPPER_SNAKE_CASE` | `MONTH_ORDER`, `PAN_EU_COUNTRIES` |
|
||||
| Props callbacks | `on<Event>` | `onFilterChange`, `onAdsUpload` |
|
||||
|
||||
## API Route Pattern
|
||||
|
||||
All routes in `/api/` follow this structure:
|
||||
|
||||
```typescript
|
||||
import type { VercelRequest, VercelResponse } from '@vercel/node';
|
||||
|
||||
const RESOURCE_DROPBOX_URL = "https://www.dropbox.com/...&dl=1";
|
||||
|
||||
export default async function handler(req: VercelRequest, res: VercelResponse) {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
|
||||
if (req.method === 'OPTIONS') return res.status(200).end();
|
||||
|
||||
try {
|
||||
console.log('[route-name] Fetching from Dropbox...');
|
||||
const response = await fetch(RESOURCE_DROPBOX_URL, {
|
||||
cache: 'no-store',
|
||||
headers: { 'Pragma': 'no-cache', 'Cache-Control': 'no-cache' }
|
||||
});
|
||||
if (!response.ok) throw new Error(`Dropbox responded with ${response.status}`);
|
||||
|
||||
const buffer = await response.arrayBuffer();
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.status(200).send(Buffer.from(buffer));
|
||||
} catch (error: any) {
|
||||
console.error('[route-name] Error:', error);
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Data Processing
|
||||
|
||||
All processing lives in `services/dataProcessor.ts`. See [references/data-processing.md](references/data-processing.md) for detailed patterns.
|
||||
|
||||
Key rules:
|
||||
- Use `getColumnValue(row, aliases[])` for flexible column mapping (supports multiple header names)
|
||||
- Parse currencies with `parseCurrency()` — handles both EU (`1.234,56`) and US (`1,234.56`) formats
|
||||
- Normalize months with `normalizeMonth()` — handles English, Spanish, numeric, and Excel serial dates
|
||||
- Filter with `isAllowedCustomer()` before returning processed data
|
||||
- `PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES']` is the default when no customer is selected
|
||||
|
||||
## Styling Standards
|
||||
|
||||
Dark theme with Tailwind CSS (loaded via CDN). No custom CSS files.
|
||||
|
||||
**Color system:**
|
||||
- Backgrounds: `bg-slate-950` (page), `bg-slate-900` (cards), `bg-slate-800` (inputs/hover)
|
||||
- Text: `text-slate-100` (primary), `text-slate-300` (secondary), `text-slate-400`/`text-slate-500` (muted)
|
||||
- Borders: `border-slate-700`
|
||||
- Accent colors: indigo (`#6366f1`), pink (`#ec4899`), emerald (`#10b981`), amber (`#f59e0b`)
|
||||
|
||||
**Responsive (mobile-first):**
|
||||
```tsx
|
||||
className="text-xs md:text-sm px-3 py-2 md:px-6 md:py-3"
|
||||
className="flex flex-col gap-3 md:flex-row"
|
||||
className="grid grid-cols-2 md:grid-cols-4 gap-4"
|
||||
```
|
||||
|
||||
**Interactive states:**
|
||||
```tsx
|
||||
className="hover:bg-white/[0.02] transition-colors"
|
||||
className="hover:scale-105 transition-all"
|
||||
```
|
||||
|
||||
**Badge pattern:**
|
||||
```tsx
|
||||
className="inline-flex items-center gap-1.5 px-2 py-1 rounded bg-gradient-to-br from-amber-500 to-orange-500 text-white border border-amber-400/50 shadow-lg"
|
||||
```
|
||||
|
||||
## Types
|
||||
|
||||
All interfaces in `types.ts`. See [references/type-definitions.md](references/type-definitions.md) for full details.
|
||||
|
||||
Core types: `SalesRecord`, `AdsRecord`, `TrafficRecord`, `ForecastRecord`, `FilterState`, `AggregatedData`, `PivotRow`, `CombinedKPIs`, `GrowthMetric`.
|
||||
|
||||
## Excel/CSV Processing
|
||||
|
||||
- **CSV**: PapaParse with `{ header: true, skipEmptyLines: true }`
|
||||
- **Excel**: XLSX library with `XLSX.read(buffer, { type: 'array' })`, then `sheet_to_json(worksheet, { header: 1 })`
|
||||
- Always filter: `.filter(r => r.year > 2023 && isAllowedCustomer(r.customer))`
|
||||
- Support multiple column header aliases for robust parsing
|
||||
|
||||
## Caching
|
||||
|
||||
IndexedDB via `services/storage.ts` with schema versioning. Cache data on load, serve cached immediately, refresh in background.
|
||||
@@ -0,0 +1,114 @@
|
||||
# Data Processing Patterns
|
||||
|
||||
## Table of Contents
|
||||
- [Column Mapping](#column-mapping)
|
||||
- [Currency Parsing](#currency-parsing)
|
||||
- [Month Normalization](#month-normalization)
|
||||
- [CSV Processing](#csv-processing)
|
||||
- [Excel Processing](#excel-processing)
|
||||
- [Filtering](#filtering)
|
||||
- [Aggregation](#aggregation)
|
||||
- [Key Constants](#key-constants)
|
||||
|
||||
## Column Mapping
|
||||
|
||||
Use `getColumnValue(row, aliases[])` to flexibly extract values from rows with varying header names:
|
||||
|
||||
```typescript
|
||||
const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY']);
|
||||
const asin = getColumnValue(row, ['CUSTOMER REFERENCE', 'AMAZON ASIN', 'ASIN', 'PRODUCT ID']);
|
||||
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Item No']);
|
||||
```
|
||||
|
||||
The function normalizes keys to lowercase and returns the first matching alias value.
|
||||
|
||||
## Currency Parsing
|
||||
|
||||
`parseCurrency()` handles EU and US formats:
|
||||
- EU: `1.234,56` → removes dots, replaces comma with period
|
||||
- US: `1,234.56` → removes commas
|
||||
- Strips currency symbols (`€`, `$`, `£`)
|
||||
- Returns `0` on parse failure
|
||||
|
||||
`parseUnits()` for integers — removes all dots and commas, parses as integer.
|
||||
|
||||
## Month Normalization
|
||||
|
||||
`normalizeMonth()` handles:
|
||||
- English short/long: `Jan`, `January`
|
||||
- Spanish: `Enero` → `Jan`, `Febrero` → `Feb`, `Marzo` → `Mar`, `Abril` → `Apr`, `Agosto` → `Aug`, `Diciembre` → `Dec`
|
||||
- Numeric: `01` → `Jan`, `1` → `Jan`
|
||||
- Combined: `Apr-23` → `Apr` (extracts month part)
|
||||
- Excel serial dates: `45544` → converts to month name
|
||||
|
||||
Mapping via `MONTH_MAP` constant (lowercase keys to 3-letter English months).
|
||||
|
||||
## CSV Processing
|
||||
|
||||
```typescript
|
||||
Papa.parse(fileOrContent, {
|
||||
header: true,
|
||||
skipEmptyLines: true,
|
||||
complete: (results) => {
|
||||
const data = results.data
|
||||
.map((row, index) => mapRowToRecord(row, index))
|
||||
.filter(r => r.year > 2023 && isAllowedCustomer(r.customer));
|
||||
resolve(data);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
## Excel Processing
|
||||
|
||||
```typescript
|
||||
const arrayBuffer = await file.arrayBuffer();
|
||||
const workbook = XLSX.read(arrayBuffer, { type: 'array' });
|
||||
const sheetName = workbook.SheetNames[0];
|
||||
const worksheet = workbook.Sheets[sheetName];
|
||||
const rawData = XLSX.utils.sheet_to_json(worksheet, { header: 1 });
|
||||
|
||||
// Header row is index 0, data starts at index 1
|
||||
const headers = rawData[0];
|
||||
const data = rawData.slice(1).map((row, index) => {
|
||||
const obj = Object.fromEntries(headers.map((h, i) => [h, row[i]]));
|
||||
return mapRowToRecord(obj, index);
|
||||
});
|
||||
```
|
||||
|
||||
For multi-sheet workbooks (like Ads Weekly), iterate `workbook.SheetNames` and process each sheet with the year derived from the sheet name.
|
||||
|
||||
## Filtering
|
||||
|
||||
### Sales Filtering (`filterData`)
|
||||
Multi-dimensional filtering across: customer, year, month, week, line, asin, sku, title, stock, vendorStock, woc, bulkSearch, and columnFilters.
|
||||
|
||||
### Ads Filtering (`filterAdsData`)
|
||||
When no customer filter is selected, defaults to `PAN_EU_COUNTRIES` only. This means unfiltered ads data shows DE + IT + FR + ES combined.
|
||||
|
||||
### Column Filters
|
||||
Support Excel-style operators: `equals`, `notEquals`, `contains`, `notContains`, `startsWith`, `endsWith`, `gt`, `lt`, `gte`, `lte`. Plus `selectedValues` for checkbox filtering and `sort` for column sorting.
|
||||
|
||||
### Numeric Conditions (`checkNumericConditions`)
|
||||
Supports range strings (`"10-20"`), comparison operators (`">5"`, `"<=100"`), and special labels (`"Out of Stock"`, `"Low Stock"`, `"In Stock"`).
|
||||
|
||||
## Aggregation
|
||||
|
||||
`aggregateData()` produces:
|
||||
- `totalSellOut`, `totalUnits` — grand totals
|
||||
- `totalsByYear` — `Record<string, { sellOut, units }>`
|
||||
- `byLine` — revenue and units per product line
|
||||
- `byCustomer` — revenue per marketplace
|
||||
- `seasonality` — monthly trends across years
|
||||
- `topMovers`, `bottomMovers` — YoY growth/decline rankings
|
||||
- `comparisonPeriods` — which periods are being compared
|
||||
- `topLinesSplit`, `byCustomerSplit`, `byLineOverviewSplit` — yearly breakdown data
|
||||
|
||||
## Key Constants
|
||||
|
||||
```typescript
|
||||
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
||||
const PAN_EU_COUNTRIES = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES'];
|
||||
const ALLOWED_CUSTOMERS = ['Amazon DE', 'Amazon IT', 'Amazon FR', 'Amazon ES', 'Amazon UK', 'Amazon SC'];
|
||||
```
|
||||
|
||||
Country name normalization maps: "Germany"/"Deutschland"/"DE"/"Alemania" → "Amazon DE", and similar for other countries.
|
||||
@@ -0,0 +1,179 @@
|
||||
# Type Definitions
|
||||
|
||||
All types live in `types.ts` at the project root.
|
||||
|
||||
## Core Record Types
|
||||
|
||||
```typescript
|
||||
interface SalesRecord {
|
||||
id: string;
|
||||
customer: string; // "Amazon DE", "Amazon UK", etc.
|
||||
year: number;
|
||||
month: string; // "Apr-23" format
|
||||
week?: number;
|
||||
asin: string;
|
||||
sku: string;
|
||||
title: string;
|
||||
articleName: string;
|
||||
units: number;
|
||||
sellOut: number; // € value
|
||||
line: string; // Product line
|
||||
}
|
||||
|
||||
interface AdsRecord {
|
||||
country: string; // "Amazon DE", etc.
|
||||
year: number;
|
||||
week: number;
|
||||
asin: string;
|
||||
cost: number;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
cpc: number;
|
||||
ctr: number;
|
||||
acos: number;
|
||||
conversions: number;
|
||||
attributedUnits30d: number;
|
||||
attributedSales30d: number;
|
||||
}
|
||||
|
||||
interface TrafficRecord {
|
||||
country: string;
|
||||
year: number;
|
||||
week: number;
|
||||
asin: string;
|
||||
glanceViews: number;
|
||||
}
|
||||
|
||||
interface ForecastRecord {
|
||||
asin: string;
|
||||
annualForecast: number;
|
||||
sku?: string;
|
||||
title?: string;
|
||||
line?: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Filter Types
|
||||
|
||||
```typescript
|
||||
interface FilterState {
|
||||
customer: string[];
|
||||
year: string[];
|
||||
month: string[];
|
||||
line: string[];
|
||||
asin: string[];
|
||||
sku: string[];
|
||||
title: string[];
|
||||
week: string[];
|
||||
stock: string[];
|
||||
vendorStock: string[];
|
||||
woc: string[]; // Weeks of Coverage
|
||||
bulkSearch: string;
|
||||
columnFilters: Record<string, ColumnFilterCondition>;
|
||||
}
|
||||
|
||||
interface ColumnFilterCondition {
|
||||
textFilter?: {
|
||||
operator: 'equals' | 'notEquals' | 'contains' | 'notContains' | 'startsWith' | 'notStartsWith' | 'endsWith' | 'notEndsWith' | 'gt' | 'lt' | 'gte' | 'lte';
|
||||
value: string;
|
||||
};
|
||||
selectedValues?: string[];
|
||||
sort?: 'asc' | 'desc';
|
||||
}
|
||||
```
|
||||
|
||||
## Aggregated Data
|
||||
|
||||
```typescript
|
||||
interface AggregatedData {
|
||||
totalSellOut: number;
|
||||
totalUnits: number;
|
||||
totalsByYear: Record<string, { sellOut: number; units: number }>;
|
||||
byLine: { name: string; value: number; units: number }[];
|
||||
byCustomer: { name: string; value: number }[];
|
||||
seasonality: SeasonalityPoint[];
|
||||
seasonalityUnits: SeasonalityPoint[];
|
||||
availableYears: string[];
|
||||
topMovers: GrowthMetric[];
|
||||
bottomMovers: GrowthMetric[];
|
||||
comparisonPeriods: { current: string; previous: string };
|
||||
topLinesSplit: YearlySplitData[];
|
||||
byCustomerSplit: YearlySplitData[];
|
||||
byLineOverviewSplit: YearlySplitData[];
|
||||
}
|
||||
```
|
||||
|
||||
## Growth Metrics
|
||||
|
||||
```typescript
|
||||
interface GrowthMetric {
|
||||
line: string;
|
||||
currentYearSellOut: number;
|
||||
previousYearSellOut: number;
|
||||
sellOutGrowthValue: number;
|
||||
sellOutGrowthPercentage: number;
|
||||
currentYearUnits: number;
|
||||
previousYearUnits: number;
|
||||
unitsGrowthValue: number;
|
||||
unitsGrowthPercentage: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Combined KPIs (Sales + Ads merged)
|
||||
|
||||
```typescript
|
||||
interface CombinedKPIs {
|
||||
id: string;
|
||||
marketplace: string;
|
||||
customer: string;
|
||||
month: string;
|
||||
week: number;
|
||||
year: number;
|
||||
asin: string;
|
||||
title: string;
|
||||
line: string;
|
||||
sku: string;
|
||||
salesTotal: number;
|
||||
unitsTotal: number;
|
||||
salesAds: number;
|
||||
unitsAds: number;
|
||||
cost: number;
|
||||
clicks: number;
|
||||
impressions: number;
|
||||
conversions: number;
|
||||
salesOrganic: number;
|
||||
unitsOrganic: number;
|
||||
paidSalesShare: number;
|
||||
organicSalesShare: number;
|
||||
acos: number;
|
||||
tacos: number;
|
||||
roas: number;
|
||||
ctr: number;
|
||||
cpc: number;
|
||||
cvrUnits: number;
|
||||
glanceViews: number;
|
||||
avgWeeklySales?: number;
|
||||
}
|
||||
```
|
||||
|
||||
## Pivot Table
|
||||
|
||||
```typescript
|
||||
interface PivotRow {
|
||||
id: string;
|
||||
customer: string;
|
||||
line: string;
|
||||
title: string;
|
||||
articleName: string;
|
||||
sku: string;
|
||||
asin: string;
|
||||
totalsByYear: Record<string, YearlyData>;
|
||||
months: MonthlyPivot[]; // Always 12 elements
|
||||
adsByYear?: Record<string, {
|
||||
adSpend: number;
|
||||
attributedSales: number;
|
||||
acos: number;
|
||||
tacos: number;
|
||||
}>;
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user