feat: Integrate PapaParse for CSV handling

Adds papaparse as a dependency and updates the data processing service to use it for more robust CSV file parsing. This replaces manual CSV parsing logic with a dedicated library, improving reliability and handling of various CSV formats.

Also renames the `MoversIcon` to `TrendingIcon` to better reflect its usage in indicating trending performance metrics.
This commit is contained in:
Christian
2025-12-11 14:03:33 +01:00
parent 9ba63ab8f8
commit 31eed97ec4
14 changed files with 1311 additions and 1333 deletions
+325 -55
View File
@@ -1,12 +1,13 @@
import { SalesRecord, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
import { SalesRecord, AdsRecord, CombinedKPIs, FilterState, AggregatedData, LineGrowthMetric, ItemGrowthMetric, SeasonalityPoint, YearlySplitData, PivotRow, YearlyData, TimeSeriesData, ComparisonTimeSeriesPoint } from '../types';
import * as XLSX from 'xlsx';
import Papa from 'papaparse';
// Helper to parse currency values handling both EU (1.234,56) and US/Standard (1,234.56 or 1234.56) formats
const parseCurrency = (value: string): number => {
if (!value) return 0;
// Remove currency symbol and whitespace
let clean = value.replace(/[€\s]/g, '').trim();
let clean = value.replace(/[€\s]/g, '').trim();
// HEURISTIC:
// If it contains a comma, we assume it's likely European format (Decimal separator)
@@ -14,17 +15,27 @@ const parseCurrency = (value: string): number => {
// But given the context (DE data), comma is usually decimal.
// Case A: European Format (e.g., "277.179,09" or "50,00")
if (clean.includes(',')) {
// If it has dots (thousands), remove them
clean = clean.replace(/\./g, '');
// Replace decimal comma with dot
if (clean.includes(',') && !clean.includes('.') && clean.indexOf(',') > clean.length - 4) {
clean = clean.replace(',', '.');
return parseFloat(clean);
}
else if (clean.includes(',') && clean.includes('.')) {
// Mixed: 1.234,56
if (clean.indexOf(',') > clean.indexOf('.')) {
clean = clean.replace(/\./g, '').replace(',', '.');
} else {
// 1,234.56
clean = clean.replace(/,/g, '');
}
return parseFloat(clean);
}
else if (clean.includes(',')) {
// Likely EU decimal
clean = clean.replace(',', '.');
return parseFloat(clean);
}
// Case B: Standard/US Format or Clean Number (e.g. "277179.09" or "1000")
// Just remove any potential thousands separator commas (if any exist and we didn't catch them above)
// and parse.
clean = clean.replace(/,/g, '');
const num = parseFloat(clean);
@@ -41,12 +52,38 @@ const parseUnits = (value: string): number => {
const MONTH_ORDER = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
// Mapping for Spanish Month Names
const SPANISH_MONTHS: Record<string, string> = {
'ene': 'Jan', 'enero': 'Jan',
'feb': 'Feb', 'febrero': 'Feb',
'mar': 'Mar', 'marzo': 'Mar',
'abr': 'Apr', 'abril': 'Apr',
'may': 'May', 'mayo': 'May',
'jun': 'Jun', 'junio': 'Jun',
'jul': 'Jul', 'julio': 'Jul',
'ago': 'Aug', 'agosto': 'Aug',
'sep': 'Sep', 'septiembre': 'Sep', 'set': 'Sep', 'setiembre': 'Sep',
'oct': 'Oct', 'octubre': 'Oct',
'nov': 'Nov', 'noviembre': 'Nov',
'dic': 'Dec', 'diciembre': 'Dec'
};
// Robust Month Normalizer
const normalizeMonth = (rawMonth: string): string => {
if (!rawMonth) return '';
let m = rawMonth.trim();
// Handle numeric months "01", "1", "01-2023" (start with digits)
// If it's a full date string like "2023-04-01" or "01/04/2023"
if (m.includes('/') || m.includes('-')) {
const date = new Date(m);
if (!isNaN(date.getTime())) {
const monthIdx = date.getMonth();
const yearShort = date.getFullYear().toString().slice(2);
return `${MONTH_ORDER[monthIdx]}-${yearShort}`;
}
}
const numMatch = m.match(/^(\d{1,2})([^\d]|$)/);
if (numMatch) {
const num = parseInt(numMatch[1]);
@@ -55,26 +92,37 @@ const normalizeMonth = (rawMonth: string): string => {
// Handle text months "Apr-23", "Apr 23", "April"
// Extract first sequence of letters
const alphaMatch = m.match(/([a-zA-Z]+)/);
const alphaMatch = m.match(/([a-zA-Z\u00C0-\u00FF]+)/); // Include accented chars for Spanish
if (alphaMatch) {
m = alphaMatch[1];
let alpha = alphaMatch[1].toLowerCase();
// Check Spanish mapping first
if (SPANISH_MONTHS[alpha]) {
m = SPANISH_MONTHS[alpha];
} else {
// Default to first 3 chars capitalize (English)
if (alpha.length > 3) alpha = alpha.substring(0, 3);
m = alpha.charAt(0).toUpperCase() + alpha.slice(1);
}
}
// Take first 3 characters
if (m.length > 3) {
m = m.substring(0, 3);
// Try to grab year from original string to append (e.g. "Apr-23")
const yearMatch = rawMonth.match(/(\d{2,4})/);
if (yearMatch) {
let y = yearMatch[1];
if (y.length === 4) y = y.slice(2);
// Only append if year is not part of the month name logic
if (!m.includes('-')) {
return `${m}-${y}`;
}
}
// Capitalize first letter, lowercase rest
m = m.charAt(0).toUpperCase() + m.slice(1).toLowerCase();
return m;
};
// Robust CSV Column Value Extractor
// Handles case-insensitivity, trimming, multiple potential header aliases, AND ignores empty values to find fallbacks.
const getColumnValue = (row: any, aliases: string[]): string => {
const rowKeys = Object.keys(row);
// Create a map of normalized keys in the row to the actual keys
const normalizedRowKeys: Record<string, string> = {};
rowKeys.forEach(k => {
normalizedRowKeys[k.trim().toLowerCase()] = k;
@@ -87,8 +135,6 @@ const getColumnValue = (row: any, aliases: string[]): string => {
const val = row[actualKey];
if (val !== undefined && val !== null) {
const strVal = String(val).trim();
// CRITICAL FIX: Only return if the value is NOT empty.
// This allows falling back to the next alias if the first matching column exists but is empty.
if (strVal.length > 0) {
return strVal;
}
@@ -98,37 +144,38 @@ const getColumnValue = (row: any, aliases: string[]): string => {
return '';
};
// Extracted Mapping Function
// --- SALES / SELL OUT MAPPING ---
const mapRowToRecord = (row: any, index: number): SalesRecord => {
const customer = getColumnValue(row, ['NEW CUSTOMER', 'Customer', 'Client', 'Account', 'Partner', 'COUNTRY', 'Country', 'Market']) || 'Unknown';
const yearStr = getColumnValue(row, ['YEAR', 'Year', 'D']);
const year = parseInt(yearStr) || 0;
// 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 month = normalizeMonth(monthStr);
// BACKFILL YEAR if missing but present in Month (e.g. "Apr-23")
if (year === 0 && month.includes('-')) {
const parts = month.split('-');
if (parts.length === 2) {
const yPart = parts[1];
// assume 20xx for 2 digits
if (yPart.length === 2) year = 2000 + parseInt(yPart);
else if (yPart.length === 4) year = parseInt(yPart);
}
}
const weekStr = getColumnValue(row, ['WEEK', 'Week', 'CW', 'Semana', 'KW', 'E']);
const weekNum = weekStr ? parseInt(weekStr.replace(/cw/i, '').trim(), 10) : NaN;
const week = isNaN(weekNum) ? undefined : weekNum;
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Other';
const line = getColumnValue(row, ['LINE', 'Line', 'Product Line']) || 'Unassigned';
// Updated ASIN priority list based on user feedback
const asin = getColumnValue(row, [
'CUSTOMER REFERENCE',
'AMAZON ASIN',
'ASIN',
'Asin',
'PRODUCT ID',
'ITEM IDENTIFIER',
'ASIN NO.',
'Product ASIN',
'IDENTIFIER'
'CUSTOMER REFERENCE', 'AMAZON ASIN', 'ASIN', 'Asin', 'PRODUCT ID', 'ITEM IDENTIFIER', 'ASIN NO.', 'Product ASIN', 'IDENTIFIER'
]);
const sku = getColumnValue(row, ['RAW ARTICLE NO.', 'SKU', 'Sku', 'Item No']);
// Prioritize 'Title' column, fallback to 'Article Name' columns
const title = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Title', 'TITLE', 'Product Title', 'Article Name', 'ArticleName']);
// Legacy/Backup field
const articleName = getColumnValue(row, ['ARTICLE NAME (Craze)', 'Article Name', 'ArticleName', 'Title']);
const unitsRaw = getColumnValue(row, ['UNITS', 'Units', 'Quantity', 'Qty']);
@@ -152,24 +199,24 @@ const mapRowToRecord = (row: any, index: number): SalesRecord => {
export const processCSV = (fileOrContent: File | string): Promise<SalesRecord[]> => {
return new Promise((resolve, reject) => {
// @ts-ignore - PapaParse is loaded globally via CDN
// @ts-ignore
Papa.parse(fileOrContent, {
header: true,
// delimiter: ";", // Allow auto-detect
skipEmptyLines: true,
complete: (results: any) => {
try {
const data: SalesRecord[] = results.data.map((row: any, index: number) => {
return mapRowToRecord(row, index);
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other'); // Validation: Exclude 2022 and require line
})
// Relaxed filtering: Only exclude rows with absolutely no year info even after backfill
.filter((r: SalesRecord) => r.year > 0);
resolve(data);
} catch (err) {
reject(err);
}
},
error: (error: any) => {
reject(error);
}
error: (error: any) => reject(error)
});
});
};
@@ -180,15 +227,13 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
const workbook = XLSX.read(arrayBuffer);
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Convert to JSON
// raw: false attempts to format the cell (e.g. dates), but for robustness we often prefer raw values or defval
// Using { defval: "" } ensures empty cells are present as empty strings if needed, but key logic handles missing keys.
const jsonData = XLSX.utils.sheet_to_json(worksheet, { defval: "" });
const data: SalesRecord[] = jsonData.map((row: any, index: number) => {
return mapRowToRecord(row, index);
}).filter((r: SalesRecord) => r.year !== 2022 && r.line && r.line !== 'Other');
})
// Relaxed filtering
.filter((r: SalesRecord) => r.year > 0);
return data;
} catch (error) {
@@ -197,14 +242,234 @@ export const processExcel = async (file: File): Promise<SalesRecord[]> => {
}
}
// --- ADS DATA MAPPING ---
const mapCountryToMarketplace = (country: string): string => {
const c = country.toLowerCase().trim();
if (c.includes('germany') || c.includes('deutschland')) return 'AMAZON DE';
if (c.includes('spain') || c.includes('espana') || c.includes('españa')) return 'AMAZON ES';
if (c.includes('france')) return 'AMAZON FR';
if (c.includes('italy') || c.includes('italia')) return 'AMAZON IT';
if (c.includes('kingdom') || c.includes('uk') || c === 'gb') return 'AMAZON UK';
if (c.includes('netherlands') || c.includes('nederland') || c.includes('holland')) return 'AMAZON NL';
if (c.includes('sweden')) return 'AMAZON SE';
if (c.includes('poland')) return 'AMAZON PL';
if (c.includes('belgium')) return 'AMAZON BE';
if (c.includes('turkey')) return 'AMAZON TR';
return country.toUpperCase(); // Fallback
};
export const processAdsCSV = (file: File): Promise<AdsRecord[]> => {
return new Promise((resolve, reject) => {
// @ts-ignore
Papa.parse(file, {
header: false, // Index-based mapping (A=0, B=1...)
skipEmptyLines: true,
complete: (results: any) => {
try {
const data: AdsRecord[] = [];
const rows = results.data;
const len = rows.length;
for (let i = 0; i < len; i++) {
const row = rows[i];
if (!Array.isArray(row) || row.length < 12) continue;
// Check header row (Column A: Country)
const c0 = String(row[0]).trim();
if (c0.toLowerCase() === 'country' || c0.toLowerCase() === 'marketplace') continue;
// Map by Column Index (A=0, B=1... L=11)
const countryRaw = row[0];
const monthRaw = row[1];
const asin = row[2];
const costRaw = row[3];
const clicksRaw = row[4];
const impressionsRaw = row[5];
// G, H, I, J unused/calculated
const unitsRaw = row[10]; // K
const salesRaw = row[11]; // L
if (!asin || !countryRaw) continue;
data.push({
country: mapCountryToMarketplace(String(countryRaw)),
month: normalizeMonth(String(monthRaw)),
asin: String(asin).trim(),
cost: parseCurrency(String(costRaw)),
clicks: parseUnits(String(clicksRaw)),
impressions: parseUnits(String(impressionsRaw)),
attributedSales30d: parseCurrency(String(salesRaw)),
attributedUnits30d: parseUnits(String(unitsRaw)),
});
}
resolve(data);
} catch (err) {
reject(err);
}
},
error: (error: any) => reject(error)
});
});
};
export const processAdsExcel = async (file: File): Promise<AdsRecord[]> => {
try {
const arrayBuffer = await file.arrayBuffer();
const workbook = XLSX.read(arrayBuffer);
const firstSheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[firstSheetName];
// Use header: 'A' to strictly map columns by index letter as requested
const jsonData = XLSX.utils.sheet_to_json(worksheet, { header: "A", defval: "" });
const data: AdsRecord[] = jsonData.map((row: any) => {
// Check if it's a header row
if (row['A'] === 'Country' && (row['C'] === 'ASIN' || row['C'] === 'Asin')) return null;
// Map by Column Letter as requested
// A: Country, B: Month, C: ASIN, D: Cost, E: Clicks, F: Impressions
// G: CPC, H: CTR, I: ACOS, J: Conversions
// K: Units, L: Sales
const countryRaw = row['A'];
const monthRaw = row['B'];
const asin = row['C'];
const costRaw = row['D'];
const clicksRaw = row['E'];
const impressionsRaw = row['F'];
const unitsRaw = row['K'];
const salesRaw = row['L'];
if (!asin || !countryRaw) return null;
return {
country: mapCountryToMarketplace(String(countryRaw)),
month: normalizeMonth(String(monthRaw)),
asin: String(asin).trim(),
cost: parseCurrency(String(costRaw)),
clicks: parseUnits(String(clicksRaw)),
impressions: parseUnits(String(impressionsRaw)),
attributedSales30d: parseCurrency(String(salesRaw)),
attributedUnits30d: parseUnits(String(unitsRaw)),
};
}).filter((r): r is AdsRecord => r !== null);
return data;
} catch (error) {
console.error("Error processing Ads Excel:", error);
throw error;
}
};
// --- DATA MERGING ---
export const mergeSalesAndAdsData = (salesData: SalesRecord[], adsData: AdsRecord[]): CombinedKPIs[] => {
// 1. Index Ads Data for fast lookup: Key = ASIN + Marketplace + Month
const adsMap = new Map<string, AdsRecord>();
adsData.forEach(ad => {
const key = `${ad.asin.toUpperCase()}|${ad.country.toUpperCase()}|${ad.month}`;
// If duplicates exist (e.g. multiple campaigns for same ASIN), sum them up
if (adsMap.has(key)) {
const existing = adsMap.get(key)!;
existing.cost += ad.cost;
existing.clicks += ad.clicks;
existing.impressions += ad.impressions;
existing.attributedSales30d += ad.attributedSales30d;
existing.attributedUnits30d += ad.attributedUnits30d;
} else {
adsMap.set(key, { ...ad });
}
});
// 2. Iterate Sales Data and merge
const mergedData: CombinedKPIs[] = salesData.map(sale => {
const key = `${sale.asin.toUpperCase()}|${sale.customer.toUpperCase()}|${sale.month}`;
const adData = adsMap.get(key) || {
country: sale.customer,
month: sale.month,
asin: sale.asin,
cost: 0,
clicks: 0,
impressions: 0,
attributedSales30d: 0,
attributedUnits30d: 0
};
const salesTotal = sale.sellOut;
const salesAds = adData.attributedSales30d;
// Logic: Organic = Total - Ads. Max(0) to avoid negative if attribution window logic differs vs finance dates
const salesOrganic = Math.max(0, salesTotal - salesAds);
const unitsTotal = sale.units;
const unitsAds = adData.attributedUnits30d;
const unitsOrganic = Math.max(0, unitsTotal - unitsAds);
// KPIs
const acos = salesAds > 0 ? (adData.cost / salesAds) * 100 : 0;
const tacos = salesTotal > 0 ? (adData.cost / salesTotal) * 100 : 0;
const roas = adData.cost > 0 ? salesAds / adData.cost : 0;
const ctr = adData.impressions > 0 ? (adData.clicks / adData.impressions) * 100 : 0;
const cpc = adData.clicks > 0 ? adData.cost / adData.clicks : 0;
// CVR (Units / Clicks)
const cvrUnits = adData.clicks > 0 ? (unitsAds / adData.clicks) * 100 : 0;
const paidSalesShare = salesTotal > 0 ? (salesAds / salesTotal) * 100 : 0;
const organicSalesShare = salesTotal > 0 ? (salesOrganic / salesTotal) * 100 : 0;
return {
id: sale.id,
marketplace: sale.customer,
month: sale.month,
year: sale.year,
asin: sale.asin,
title: sale.title,
line: sale.line,
sku: sale.sku,
salesTotal,
unitsTotal,
salesAds,
unitsAds,
cost: adData.cost,
clicks: adData.clicks,
impressions: adData.impressions,
salesOrganic,
unitsOrganic,
paidSalesShare,
organicSalesShare,
acos,
tacos,
roas,
ctr,
cpc,
cvrUnits
};
});
return mergedData;
};
// --- EXISTING HELPERS ---
export const filterData = (data: SalesRecord[], filters: FilterState): SalesRecord[] => {
return data.filter(item => {
// Item month is already normalized
const recordMonth = item.month;
// 1. Month Logic: Handle "Apr-23" matching "Apr" filter
const recordMonth = item.month; // e.g. "Apr-23"
const pureMonth = recordMonth.split('-')[0]; // "Apr"
// 2. Filter Checks
const customerMatch = filters.customer.length === 0 || filters.customer.includes(item.customer);
const yearMatch = filters.year.length === 0 || filters.year.includes(item.year.toString());
const monthMatch = filters.month.length === 0 || filters.month.includes(recordMonth);
// Check match against pure month ("Apr") OR full month ("Apr-23") just in case filters evolve
const monthMatch = filters.month.length === 0 || filters.month.includes(pureMonth) || filters.month.includes(recordMonth);
const lineMatch = filters.line.length === 0 || filters.line.includes(item.line);
const asinMatch = filters.asin.length === 0 || filters.asin.includes(item.asin);
const skuMatch = filters.sku.length === 0 || filters.sku.includes(item.sku);
@@ -227,17 +492,22 @@ const calculateSeasonality = (data: SalesRecord[]): { seasonality: SeasonalityPo
data.forEach(record => {
const monthName = record.month;
// Extract year from record.month if it's in Format "Mon-YY", else use record.year
// record.year is numeric, record.month is "Apr-23".
const yearStr = record.year.toString();
yearsSet.add(yearStr);
// We need to match month name purely (Jan, Feb) for the X Axis, ignoring year
const pureMonth = monthName.split('-')[0];
if (seasonalityMap.has(monthName)) {
if (seasonalityMap.has(pureMonth)) {
// Sell Out
const entrySO = seasonalityMap.get(monthName)!;
const entrySO = seasonalityMap.get(pureMonth)!;
const currentValSO = (entrySO[yearStr] as number) || 0;
entrySO[yearStr] = currentValSO + record.sellOut;
// Units
const entryUnits = seasonalityUnitsMap.get(monthName)!;
const entryUnits = seasonalityUnitsMap.get(pureMonth)!;
const currentValUnits = (entryUnits[yearStr] as number) || 0;
entryUnits[yearStr] = currentValUnits + record.units;
}
@@ -629,7 +899,7 @@ export const pivotSalesData = (data: SalesRecord[], dimensions: string[] = ['tit
}
const row = map.get(key)!;
const monthPart = record.month;
const monthPart = record.month.split('-')[0]; // Handle "Apr-23" -> "Apr"
const monthIdx = MONTH_ORDER.indexOf(monthPart);
const yearStr = record.year.toString();
+55 -46
View File
@@ -2,39 +2,49 @@
import { GoogleGenAI } from "@google/genai";
import { AggregatedData } from "../types";
// Declare process to avoid TypeScript errors without causing aggressive bundler shims
declare const process: any;
const SYSTEM_INSTRUCTION = `
You are an expert Data Analyst Assistant for "Craze Analytix".
You have access to a structured dataset of sales performance including Revenue (Sell Out), Units, Product Lines, and Seasonality.
You are a senior data analyst assistant for a retail dashboard called "Craze Analytix".
You have access to a detailed report of the currently filtered sales data.
The data includes Sell Out (Revenue in €), Units Sold, Product Lines, Customers/Markets, and Seasonality trends.
Your Capabilities:
1. **Analyze Trends**: Use the provided Seasonality and Yearly Breakdown data.
2. **Perform Calculations**: You have access to detailed Product Line totals. You MUST calculate growth percentages, market shares, and sums dynamically if the user asks.
3. **Compare**: Compare performance between years (e.g., 2024 vs 2025).
Rules:
- If the user asks for a calculation (e.g., "What is the % share of Line X?"), perform the math using the provided numbers.
- Always format currency as € (e.g., €1,200) and units with 'u' or 'units' (e.g., 500 units).
- Be concise but insightful. Point out significant growth or decline.
- If data is missing for a specific query, state clearly that it is not in the current filtered view.
Your goal is to answer user questions specific to the provided data.
- If asked about "Trends" or "Seasonality", look at the Monthly Seasonality section.
- If asked about "Growth" or "Decline", look at the Top/Bottom Movers sections.
- If asked about specific Product Lines, look at the Product Line Breakdown.
- Always format numbers clearly (e.g., "€1.2M", "€5,200", "15k units").
- When comparing years, calculate the percentage difference if not explicitly provided.
- Keep answers professional, concise, and business-focused.
`;
// Helper to get API key safely
const getApiKey = (): string | undefined => {
try {
return process.env.API_KEY;
} catch (e) {
return undefined;
}
};
const formatCurrency = (val: number) => `${val.toLocaleString(undefined, {minimumFractionDigits: 0, maximumFractionDigits: 0})}`;
const formatUnits = (val: number) => `${val.toLocaleString()} units`;
export const queryGemini = async (
apiKey: string,
question: string,
context: AggregatedData,
filteredRecordCount: number
): Promise<string> => {
const apiKey = getApiKey();
if (!apiKey) {
return "Please provide your Gemini API Key in the settings to enable the AI assistant.";
return "API Key is missing. Please configure your environment variables (API_KEY) or check your .env file.";
}
try {
// Ensure the key is clean of whitespace
const ai = new GoogleGenAI({ apiKey: apiKey.trim() });
const ai = new GoogleGenAI({ apiKey });
// --- CONTEXT GENERATION ---
// We construct a structured report mirroring the dashboard charts
@@ -46,62 +56,64 @@ export const queryGemini = async (
.join('\n');
// 2. Seasonality (Line Chart Data)
// We simplify this to a CSV-like list for the AI to parse trends
const seasonalitySummary = context.seasonality.map(p => {
// Extract values for each year in the point
const yearValues = context.availableYears.map(y => `${y}: ${formatCurrency(p[y] as number || 0)}`).join(', ');
return ` - ${p.name}: [${yearValues}]`;
}).join('\n');
// 3. Growth/Decline
// 3. Top Movers (Growth Table) - Limit to Top 10
const growthSummary = context.topMovers.slice(0, 10).map(m =>
` - ${m.line}: +€${m.sellOutGrowthValue.toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
).join('\n');
// 4. Declining Movers (Decline Table) - Limit to Top 10
const declineSummary = context.bottomMovers.slice(0, 10).map(m =>
` - ${m.line}: -€${Math.abs(m.sellOutGrowthValue).toLocaleString()} (${m.sellOutGrowthPercentage.toFixed(1)}%)`
).join('\n');
// 4. DETAILED BREAKDOWN (For Calculations)
// We provide a JSON-like structure of ALL top product lines so the AI can compute shares/totals.
// We limit this to top 100 to avoid token limits, which covers most relevant data.
const detailedLines = context.byLine.slice(0, 100).map(l => ({
name: l.name,
revenue: l.value,
units: l.units
}));
// 5. Product Lines Overview (Bar Charts) - Limit to Top 50 to save tokens but give depth
const topLinesSummary = context.byLine.slice(0, 50).map((l, i) =>
` ${i+1}. ${l.name}: ${formatCurrency(l.value)} | ${formatUnits(l.units)}`
).join('\n');
// 6. Customer Distribution (Customer Chart)
const customerSummary = context.byCustomer.map(c =>
` - ${c.name}: ${formatCurrency(c.value)}`
).join('\n');
const fullReport = `
REPORT CONTEXT (Based on Current Filters):
------------------------------------------
GLOBAL METRICS:
REPORT CONTEXT:
----------------
GLOBAL TOTALS:
Total Sell Out: ${formatCurrency(context.totalSellOut)}
Total Units: ${formatUnits(context.totalUnits)}
Records Analyzed: ${filteredRecordCount}
Years Available: ${context.availableYears.join(', ')}
YEARLY TOTALS:
YEARLY BREAKDOWN:
${yearlySummary}
MONTHLY TRENDS (Seasonality):
MONTHLY SEASONALITY (Revenue Trends):
${seasonalitySummary}
TOP PERFORMERS (Growth YoY):
FASTEST GROWING LINES (Year-over-Year):
${growthSummary}
WORST PERFORMERS (Decline YoY):
DECLINING LINES (Year-over-Year):
${declineSummary}
DETAILED PRODUCT LINE DATA (Use this for specific calculations):
${JSON.stringify(detailedLines, null, 2)}
TOP PRODUCT LINES (Revenue & Units):
${topLinesSummary}
PERFORMANCE BY CUSTOMER:
${customerSummary}
`;
const response = await ai.models.generateContent({
model: 'gemini-3-pro-preview', // Updated to the latest capable model for complex reasoning
contents: [
{
role: 'user',
parts: [{ text: `Context Data:\n${fullReport}\n\nUser Question: ${question}` }]
}
],
model: 'gemini-2.5-flash',
contents: `Context Data:\n${fullReport}\n\nUser Question: ${question}`,
config: {
systemInstruction: SYSTEM_INSTRUCTION,
}
@@ -111,11 +123,8 @@ ${JSON.stringify(detailedLines, null, 2)}
} catch (error: any) {
console.error("Gemini API Error:", error);
if (error.message && error.message.includes("403")) {
return "Error 403: Invalid API Key. Please check your key in the settings.";
}
if (error.message && error.message.includes("429")) {
return "Error 429: Quota exceeded. You are sending too many requests.";
if (error.message && error.message.includes("Not implemented on this platform")) {
return "System Error: The AI SDK detected a platform mismatch.";
}
return `Error: ${error.message || "An unexpected error occurred while analyzing the data."}`;