import fetch from 'node-fetch'; import { TimeManager } from '../utils/time.utils.js'; import { RedisService } from './redis.service.js'; const METRIC_IDS = { PLACED_ORDER: 'Y8cqcF', SHIPPED_ORDER: 'VExpdL', ACCOUNT_CREATED: 'TeeypV', CANCELED_ORDER: 'YjVMNg', NEW_BLOG_POST: 'YcxeDr', PAYMENT_REFUNDED: 'R7XUYh' }; export class EventsService { constructor(apiKey, apiRevision) { this.apiKey = apiKey; this.apiRevision = apiRevision; this.baseUrl = 'https://a.klaviyo.com/api'; this.timeManager = new TimeManager(); this.redisService = new RedisService(); } async getEvents(params = {}) { try { // Add request debouncing const requestKey = JSON.stringify(params); if (this._pendingRequests && this._pendingRequests[requestKey]) { return this._pendingRequests[requestKey]; } // Try to get from cache first const cacheKey = this.redisService._getCacheKey('events', params); let cachedData = null; try { cachedData = await this.redisService.get(`${cacheKey}:raw`); if (cachedData) { cachedData.data = this._transformEvents(cachedData.data); return cachedData; } } catch (cacheError) { console.warn('[EventsService] Cache error:', cacheError); } this._pendingRequests = this._pendingRequests || {}; this._pendingRequests[requestKey] = (async () => { let allEvents = []; let nextCursor = params.pageCursor; let pageCount = 0; const filter = params.filter || this._buildFilter(params); do { const queryParams = new URLSearchParams(); if (filter) { queryParams.append('filter', filter); } queryParams.append('sort', params.sort || '-datetime'); if (nextCursor) { queryParams.append('page[cursor]', nextCursor); } const url = `${this.baseUrl}/events?${queryParams.toString()}`; try { const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'Authorization': `Klaviyo-API-Key ${this.apiKey}`, 'revision': this.apiRevision } }); if (!response.ok) { const errorData = await response.json(); console.error('[EventsService] API Error:', errorData); throw new Error(`Klaviyo API error: ${response.status} ${response.statusText}`); } const responseData = await response.json(); allEvents = allEvents.concat(responseData.data || []); pageCount++; nextCursor = responseData.links?.next ? new URL(responseData.links.next).searchParams.get('page[cursor]') : null; if (nextCursor) { await new Promise(resolve => setTimeout(resolve, 50)); } } catch (fetchError) { console.error('[EventsService] Fetch error:', fetchError); throw fetchError; } } while (nextCursor); const transformedEvents = this._transformEvents(allEvents); const result = { data: transformedEvents, meta: { total_count: transformedEvents.length, page_count: pageCount } }; try { const ttl = this.redisService._getTTL(params.timeRange); await this.redisService.set(`${cacheKey}:raw`, result, ttl); } catch (cacheError) { console.warn('[EventsService] Cache set error:', cacheError); } delete this._pendingRequests[requestKey]; return result; })(); return await this._pendingRequests[requestKey]; } catch (error) { console.error('[EventsService] Error fetching events:', error); throw error; } } _buildFilter(params) { const filters = []; if (params.metricId) { filters.push(`equals(metric_id,"${params.metricId}")`); } if (params.startDate && params.endDate) { const startUtc = this.timeManager.formatForAPI(params.startDate); const endUtc = this.timeManager.formatForAPI(params.endDate); filters.push(`greater-or-equal(datetime,${startUtc})`); filters.push(`less-than(datetime,${endUtc})`); } if (params.profileId) { filters.push(`equals(profile_id,"${params.profileId}")`); } if (params.customFilters) { filters.push(...params.customFilters); } return filters.length > 0 ? (filters.length > 1 ? `and(${filters.join(',')})` : filters[0]) : null; } async getEventsByTimeRange(timeRange, options = {}) { const range = this.timeManager.getDateRange(timeRange); if (!range) { throw new Error('Invalid time range specified'); } const params = { timeRange, startDate: range.start.toISO(), endDate: range.end.toISO(), metricId: options.metricId }; // Try to get from cache first const cacheKey = this.redisService._getCacheKey('events', params); let cachedData = null; try { cachedData = await this.redisService.get(`${cacheKey}:raw`); if (cachedData) { // Transform cached events cachedData.data = this._transformEvents(cachedData.data); return cachedData; } } catch (cacheError) { console.warn('[EventsService] Cache error:', cacheError); // Continue with API request if cache fails } return this.getEvents(params); } async calculatePeriodStats(params = {}) { try { // Add request debouncing const requestKey = JSON.stringify(params); if (this._pendingStatRequests && this._pendingStatRequests[requestKey]) { return this._pendingStatRequests[requestKey]; } // Get period dates let periodStart, periodEnd, prevPeriodStart, prevPeriodEnd; if (params.startDate && params.endDate) { periodStart = this.timeManager.toDateTime(params.startDate); periodEnd = this.timeManager.toDateTime(params.endDate); const duration = periodEnd.diff(periodStart); prevPeriodStart = periodStart.minus(duration); prevPeriodEnd = periodStart.minus({ milliseconds: 1 }); } else if (params.timeRange) { const range = this.timeManager.getDateRange(params.timeRange); periodStart = range.start; periodEnd = range.end; const duration = periodEnd.diff(periodStart); prevPeriodStart = periodStart.minus(duration); prevPeriodEnd = periodStart.minus({ milliseconds: 1 }); } // Load both current and previous period data const [orderData, shippedData, refundData, canceledData, prevPeriodData] = await Promise.all([ this.getEvents({ ...params, metricId: METRIC_IDS.PLACED_ORDER }), this.getEvents({ ...params, metricId: METRIC_IDS.SHIPPED_ORDER }), this.getEvents({ ...params, metricId: METRIC_IDS.PAYMENT_REFUNDED }), this.getEvents({ ...params, metricId: METRIC_IDS.CANCELED_ORDER }), this.getEvents({ metricId: METRIC_IDS.PLACED_ORDER, startDate: prevPeriodStart.toISO(), endDate: prevPeriodEnd.toISO() }) ]); // Transform all data const transformedOrders = this._transformEvents(orderData.data); const transformedShipped = this._transformEvents(shippedData.data); const transformedRefunds = this._transformEvents(refundData.data); const transformedCanceled = this._transformEvents(canceledData.data); const transformedPrevPeriod = this._transformEvents(prevPeriodData.data); // Calculate previous period stats const prevPeriodRevenue = transformedPrevPeriod.reduce((sum, order) => { const props = order.event_properties || {}; return sum + Number(props.TotalAmount || 0); }, 0); const prevPeriodOrders = transformedPrevPeriod.length; const prevPeriodAOV = prevPeriodOrders > 0 ? prevPeriodRevenue / prevPeriodOrders : 0; // Calculate stats with all data available const stats = { orderCount: 0, revenue: 0, averageOrderValue: 0, itemCount: 0, prevPeriodRevenue, projectedRevenue: 0, periodProgress: 0, dailyData: [], products: { list: [], total: 0, status: { backordered: 0, inStock: 0, outOfStock: 0, preorder: 0 } }, shipping: { shippedCount: 0, locations: { byState: [], byCountry: [] }, methods: {}, methodPercentages: {}, totalRevenue: 0, averageShipTime: 0, totalShipTime: 0 }, refunds: { total: 0, count: 0, reasons: {}, items: [], averageAmount: 0 }, canceledOrders: { total: 0, count: 0, reasons: {}, items: [], averageAmount: 0 }, orderTypes: { preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } }, brands: { total: 0, list: [], topBrands: [], totalRevenue: 0, averageOrderValue: 0 }, categories: { total: 0, list: [], topCategories: [], totalRevenue: 0, averageOrderValue: 0 }, hourlyOrders: Array(24).fill(0), peakOrderHour: null, bestRevenueDay: null, orderValueRange: { largest: 0, smallest: 0, largestOrderId: null, smallestOrderId: null, distribution: { under25: { count: 0, total: 0 }, under50: { count: 0, total: 0 }, under100: { count: 0, total: 0 }, under200: { count: 0, total: 0 }, over200: { count: 0, total: 0 } } } }; // Calculate period progress if (periodStart && periodEnd) { const totalDuration = periodEnd.diff(periodStart); const elapsedDuration = this.timeManager.getNow().diff(periodStart); stats.periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100)); } // Process orders const brandMap = new Map(); const categoryMap = new Map(); const dailyOrderCounts = {}; const dailyStats = new Map(); // Track best day stats let bestDay = { date: null, displayDate: null, amount: 0, orderCount: 0 }; // Initialize daily stats for the entire date range if (periodStart && periodEnd) { let currentDate = periodStart; while (currentDate <= periodEnd) { const dateKey = currentDate.toFormat('yyyy-MM-dd'); dailyStats.set(dateKey, { date: currentDate.toISO(), // ISO format for chart library timestamp: dateKey, revenue: 0, orders: 0, itemCount: 0, averageOrderValue: 0, averageItemsPerOrder: 0, hourlyOrders: Array(24).fill(0), refunds: { total: 0, count: 0, reasons: {}, items: [] }, canceledOrders: { total: 0, count: 0, reasons: {}, items: [] }, orderTypes: { preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } }, orderValueRange: { largest: 0, smallest: 0, largestOrderId: null, smallestOrderId: null, distribution: { under25: { count: 0, total: 0 }, under50: { count: 0, total: 0 }, under100: { count: 0, total: 0 }, under200: { count: 0, total: 0 }, over200: { count: 0, total: 0 } } } }); currentDate = currentDate.plus({ days: 1 }); } } // Track peak hour stats let maxHourCount = 0; let peakHour = 0; for (const order of transformedOrders) { const props = order.event_properties || {}; const items = props.Items || []; const totalAmount = Number(props.TotalAmount || 0); const datetime = this.timeManager.toDateTime(order.attributes?.datetime); const orderId = props.OrderId; // Update order counts and revenue stats.orderCount++; stats.revenue += totalAmount; stats.itemCount += items.length; // Calculate running AOV stats.averageOrderValue = stats.revenue / stats.orderCount; // Track daily stats if (datetime) { const dateKey = datetime.toFormat('yyyy-MM-dd'); const hourOfDay = datetime.hour; // Initialize stats for this date if it doesn't exist if (!dailyStats.has(dateKey)) { dailyStats.set(dateKey, { date: datetime.toISO(), // ISO format for chart library timestamp: dateKey, revenue: 0, orders: 0, itemCount: 0, averageOrderValue: 0, averageItemsPerOrder: 0, hourlyOrders: Array(24).fill(0), refunds: { total: 0, count: 0, reasons: {}, items: [] }, canceledOrders: { total: 0, count: 0, reasons: {}, items: [] }, orderTypes: { preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } }, orderValueRange: { largest: 0, smallest: 0, largestOrderId: null, smallestOrderId: null, distribution: { under25: { count: 0, total: 0 }, under50: { count: 0, total: 0 }, under100: { count: 0, total: 0 }, under200: { count: 0, total: 0 }, over200: { count: 0, total: 0 } } } }); } const dayStats = dailyStats.get(dateKey); // Update daily stats dayStats.revenue += totalAmount; dayStats.orders++; dayStats.itemCount += items.length; dayStats.hourlyOrders[hourOfDay]++; dayStats.averageOrderValue = dayStats.revenue / dayStats.orders; dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.orders; // Track best day if (dayStats.revenue > bestDay.amount) { bestDay = { date: dateKey, displayDate: datetime.toFormat('LLL d, yyyy'), amount: dayStats.revenue, orderCount: dayStats.orders }; } // Track daily order value range if (totalAmount > dayStats.orderValueRange.largest) { dayStats.orderValueRange.largest = totalAmount; dayStats.orderValueRange.largestOrderId = orderId; } if (totalAmount > 0) { if (dayStats.orderValueRange.smallest === 0 || totalAmount < dayStats.orderValueRange.smallest) { dayStats.orderValueRange.smallest = totalAmount; dayStats.orderValueRange.smallestOrderId = orderId; } } // Track order types if (props.HasPreorder) { dayStats.orderTypes.preOrders.count++; dayStats.orderTypes.preOrders.value += totalAmount; dayStats.orderTypes.preOrders.percentage = (dayStats.orderTypes.preOrders.count / dayStats.orders) * 100; dayStats.orderTypes.preOrders.averageValue = dayStats.orderTypes.preOrders.value / dayStats.orderTypes.preOrders.count; } if (props.LocalPickup) { dayStats.orderTypes.localPickup.count++; dayStats.orderTypes.localPickup.value += totalAmount; dayStats.orderTypes.localPickup.percentage = (dayStats.orderTypes.localPickup.count / dayStats.orders) * 100; dayStats.orderTypes.localPickup.averageValue = dayStats.orderTypes.localPickup.value / dayStats.orderTypes.localPickup.count; } if (props.IsOnHold) { dayStats.orderTypes.heldItems.count++; dayStats.orderTypes.heldItems.value += totalAmount; dayStats.orderTypes.heldItems.percentage = (dayStats.orderTypes.heldItems.count / dayStats.orders) * 100; dayStats.orderTypes.heldItems.averageValue = dayStats.orderTypes.heldItems.value / dayStats.orderTypes.heldItems.count; } if (props.HasDigiItem) { dayStats.orderTypes.digital.count++; dayStats.orderTypes.digital.value += totalAmount; dayStats.orderTypes.digital.percentage = (dayStats.orderTypes.digital.count / dayStats.orders) * 100; dayStats.orderTypes.digital.averageValue = dayStats.orderTypes.digital.value / dayStats.orderTypes.digital.count; } if (props.HasDigitalGC) { dayStats.orderTypes.giftCard.count++; dayStats.orderTypes.giftCard.value += totalAmount; dayStats.orderTypes.giftCard.percentage = (dayStats.orderTypes.giftCard.count / dayStats.orders) * 100; dayStats.orderTypes.giftCard.averageValue = dayStats.orderTypes.giftCard.value / dayStats.orderTypes.giftCard.count; } // Update hourly stats for peak hour calculation if (dayStats.hourlyOrders[hourOfDay] > maxHourCount) { maxHourCount = dayStats.hourlyOrders[hourOfDay]; peakHour = hourOfDay; } dailyStats.set(dateKey, dayStats); } // Track order value range if (totalAmount > stats.orderValueRange.largest) { stats.orderValueRange.largest = totalAmount; stats.orderValueRange.largestOrderId = orderId; } if (totalAmount > 0) { if (stats.orderValueRange.smallest === 0 || totalAmount < stats.orderValueRange.smallest) { stats.orderValueRange.smallest = totalAmount; stats.orderValueRange.smallestOrderId = orderId; } // Track order value distribution if (totalAmount < 25) { stats.orderValueRange.distribution.under25.count++; stats.orderValueRange.distribution.under25.total += totalAmount; } else if (totalAmount < 50) { stats.orderValueRange.distribution.under50.count++; stats.orderValueRange.distribution.under50.total += totalAmount; } else if (totalAmount < 100) { stats.orderValueRange.distribution.under100.count++; stats.orderValueRange.distribution.under100.total += totalAmount; } else if (totalAmount < 200) { stats.orderValueRange.distribution.under200.count++; stats.orderValueRange.distribution.under200.total += totalAmount; } else { stats.orderValueRange.distribution.over200.count++; stats.orderValueRange.distribution.over200.total += totalAmount; } } // Track order types with values if (props.HasPreorder) { stats.orderTypes.preOrders.count++; stats.orderTypes.preOrders.value += totalAmount; stats.orderTypes.preOrders.items.push({ orderId, amount: totalAmount, items }); stats.orderTypes.preOrders.percentage = (stats.orderTypes.preOrders.count / stats.orderCount) * 100; stats.orderTypes.preOrders.averageValue = stats.orderTypes.preOrders.value / stats.orderTypes.preOrders.count; } if (props.LocalPickup) { stats.orderTypes.localPickup.count++; stats.orderTypes.localPickup.value += totalAmount; stats.orderTypes.localPickup.items.push({ orderId, amount: totalAmount, items }); stats.orderTypes.localPickup.percentage = (stats.orderTypes.localPickup.count / stats.orderCount) * 100; stats.orderTypes.localPickup.averageValue = stats.orderTypes.localPickup.value / stats.orderTypes.localPickup.count; } if (props.IsOnHold) { stats.orderTypes.heldItems.count++; stats.orderTypes.heldItems.value += totalAmount; stats.orderTypes.heldItems.items.push({ orderId, amount: totalAmount, items }); stats.orderTypes.heldItems.percentage = (stats.orderTypes.heldItems.count / stats.orderCount) * 100; stats.orderTypes.heldItems.averageValue = stats.orderTypes.heldItems.value / stats.orderTypes.heldItems.count; } if (props.HasDigiItem) { stats.orderTypes.digital.count++; stats.orderTypes.digital.value += totalAmount; stats.orderTypes.digital.items.push({ orderId, amount: totalAmount, items }); stats.orderTypes.digital.percentage = (stats.orderTypes.digital.count / stats.orderCount) * 100; stats.orderTypes.digital.averageValue = stats.orderTypes.digital.value / stats.orderTypes.digital.count; } if (props.HasDigitalGC) { stats.orderTypes.giftCard.count++; stats.orderTypes.giftCard.value += totalAmount; stats.orderTypes.giftCard.items.push({ orderId, amount: totalAmount, items }); stats.orderTypes.giftCard.percentage = (stats.orderTypes.giftCard.count / stats.orderCount) * 100; stats.orderTypes.giftCard.averageValue = stats.orderTypes.giftCard.value / stats.orderTypes.giftCard.count; } // Track hourly and daily stats if (datetime) { const hour = datetime.hour; stats.hourlyOrders[hour]++; } // Process products and related data for (const item of items) { const productId = item.ProductID; const quantity = Number(item.Quantity || item.QuantityOrdered || 1); const revenue = Number(item.RowTotal || (item.ItemPrice * quantity) || 0); // Track item status switch(item.ItemStatus?.toLowerCase()) { case 'backordered': stats.products.status.backordered++; break; case 'out of stock': stats.products.status.outOfStock++; break; case 'preorder': stats.products.status.preorder++; break; default: stats.products.status.inStock++; } // Update product stats const existingProduct = stats.products.list.find(p => p.id === productId); if (existingProduct) { existingProduct.totalQuantity += quantity; existingProduct.totalRevenue += revenue; existingProduct.orderCount++; existingProduct.orders.add(orderId); } else { stats.products.list.push({ id: productId, sku: item.SKU, name: item.ProductName, brand: item.Brand, price: Number(item.ItemPrice || 0), ImgThumb: item.ImgThumb, totalQuantity: quantity, totalRevenue: revenue, orderCount: 1, orders: new Set([orderId]), categories: item.Categories || [], status: item.ItemStatus || 'In Stock' }); } // Update brand stats if (item.Brand) { const brand = brandMap.get(item.Brand) || { name: item.Brand, quantity: 0, revenue: 0, products: new Set() }; brand.quantity += quantity; brand.revenue += revenue; brand.products.add(productId); brandMap.set(item.Brand, brand); } // Update category stats if (item.Categories) { for (const category of item.Categories) { const categoryStats = categoryMap.get(category) || { name: category, quantity: 0, revenue: 0, products: new Set() }; categoryStats.quantity += quantity; categoryStats.revenue += revenue; categoryStats.products.add(productId); categoryMap.set(category, categoryStats); } } } } // After processing all orders if (stats.orderCount > 0) { stats.averageOrderValue = stats.revenue / stats.orderCount; stats.averageItemsPerOrder = stats.itemCount / stats.orderCount; } // Calculate projected revenue for incomplete periods if (periodStart && periodEnd) { const totalDuration = periodEnd.diff(periodStart); const elapsedDuration = this.timeManager.getNow().diff(periodStart); const periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100)); if (periodProgress > 0 && periodProgress < 100) { stats.projectedRevenue = (stats.revenue / (periodProgress / 100)); } else { stats.projectedRevenue = stats.revenue; } stats.periodProgress = periodProgress; } // Calculate trend data only for revenue if (prevPeriodRevenue > 0) { stats.trend = { revenue: ((stats.revenue - prevPeriodRevenue) / prevPeriodRevenue) * 100 }; } // Process shipped orders with better formatting const shippingMethodMap = new Map(); const locationMap = new Map(); const stateMap = new Map(); const countryMap = new Map(); for (const shipped of transformedShipped) { const props = shipped.event_properties || {}; stats.shipping.shippedCount++; // Track shipping methods const method = props.ShipMethod || props.ShippingMethod || 'Unknown'; const currentMethodCount = shippingMethodMap.get(method) || 0; shippingMethodMap.set(method, currentMethodCount + 1); // Track locations by state and country const state = props.ShippingState?.trim() || 'Unknown State'; const country = props.ShippingCountry?.trim() || 'Unknown Country'; // Track unique locations const locationKey = `${state}-${country}`; locationMap.set(locationKey, true); // Track by state const stateStats = stateMap.get(state) || { count: 0, country }; stateStats.count++; stateMap.set(state, stateStats); // Track by country const countryStats = countryMap.get(country) || { count: 0, states: new Set() }; countryStats.count++; countryStats.states.add(state); countryMap.set(country, countryStats); } // Format shipping methods stats.shipping.methods = Object.fromEntries(shippingMethodMap); stats.shipping.methodPercentages = {}; stats.shipping.methodStats = []; shippingMethodMap.forEach((count, method) => { const percentage = (count / stats.shipping.shippedCount) * 100; stats.shipping.methodPercentages[method] = percentage; stats.shipping.methodStats.push({ name: method, value: count, percentage }); }); stats.shipping.methodStats.sort((a, b) => b.value - a.value); // Format locations by state and country stats.shipping.locations = { total: locationMap.size, byState: Array.from(stateMap.entries()) .map(([state, data]) => ({ state, country: data.country, count: data.count, percentage: (data.count / stats.shipping.shippedCount) * 100 })) .sort((a, b) => b.count - a.count), byCountry: Array.from(countryMap.entries()) .map(([country, data]) => ({ country, count: data.count, states: Array.from(data.states), percentage: (data.count / stats.shipping.shippedCount) * 100 })) .sort((a, b) => b.count - a.count) }; // Process refunds with more detail for (const refund of transformedRefunds) { const props = refund.event_properties || {}; const amount = Number(props.PaymentAmount || 0); const reason = props.CancelReason || props.OrderMessage || 'No reason provided'; const datetime = this.timeManager.toDateTime(refund.attributes?.datetime); const orderId = props.OrderId || props.FromOrder; stats.refunds.total += amount; stats.refunds.count++; stats.refunds.reasons[reason] = (stats.refunds.reasons[reason] || 0) + 1; stats.refunds.items.push({ orderId, amount, reason, datetime: datetime?.toISO() }); stats.refunds.averageAmount = stats.refunds.total / stats.refunds.count; // Track daily refunds if (datetime) { const dateKey = datetime.toFormat('yyyy-MM-dd'); // Initialize stats for this date if it doesn't exist if (!dailyStats.has(dateKey)) { dailyStats.set(dateKey, { date: datetime.toISO(), // ISO format for chart library timestamp: dateKey, revenue: 0, orders: 0, itemCount: 0, averageOrderValue: 0, averageItemsPerOrder: 0, hourlyOrders: Array(24).fill(0), refunds: { total: 0, count: 0, reasons: {}, items: [] }, canceledOrders: { total: 0, count: 0, reasons: {}, items: [] }, orderTypes: { preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } }, orderValueRange: { largest: 0, smallest: 0, largestOrderId: null, smallestOrderId: null, distribution: { under25: { count: 0, total: 0 }, under50: { count: 0, total: 0 }, under100: { count: 0, total: 0 }, under200: { count: 0, total: 0 }, over200: { count: 0, total: 0 } } } }); } const dayStats = dailyStats.get(dateKey); dayStats.refunds.total += amount; dayStats.refunds.count++; dayStats.refunds.reasons[reason] = (dayStats.refunds.reasons[reason] || 0) + 1; dailyStats.set(dateKey, dayStats); } } // Process canceled orders with more detail for (const canceled of transformedCanceled) { const props = canceled.event_properties || {}; const amount = Number(props.TotalAmount || 0); const reason = props.CancelReason || props.OrderMessage || 'No reason provided'; const datetime = this.timeManager.toDateTime(canceled.attributes?.datetime); const orderId = props.OrderId || props.FromOrder; stats.canceledOrders = stats.canceledOrders || { total: 0, count: 0, reasons: {}, items: [], averageAmount: 0 }; stats.canceledOrders.total += amount; stats.canceledOrders.count++; stats.canceledOrders.reasons[reason] = (stats.canceledOrders.reasons[reason] || 0) + 1; stats.canceledOrders.items.push({ orderId, amount, reason, datetime: datetime?.toISO() }); stats.canceledOrders.averageAmount = stats.canceledOrders.total / stats.canceledOrders.count; // Track daily cancellations if (datetime) { const dateKey = datetime.toFormat('yyyy-MM-dd'); // Initialize stats for this date if it doesn't exist if (!dailyStats.has(dateKey)) { dailyStats.set(dateKey, { date: datetime.toISO(), // ISO format for chart library timestamp: dateKey, revenue: 0, orders: 0, itemCount: 0, averageOrderValue: 0, averageItemsPerOrder: 0, hourlyOrders: Array(24).fill(0), refunds: { total: 0, count: 0, reasons: {}, items: [] }, canceledOrders: { total: 0, count: 0, reasons: {}, items: [] }, orderTypes: { preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } }, orderValueRange: { largest: 0, smallest: 0, largestOrderId: null, smallestOrderId: null, distribution: { under25: { count: 0, total: 0 }, under50: { count: 0, total: 0 }, under100: { count: 0, total: 0 }, under200: { count: 0, total: 0 }, over200: { count: 0, total: 0 } } } }); } const dayStats = dailyStats.get(dateKey); dayStats.canceledOrders.total += amount; dayStats.canceledOrders.count++; dayStats.canceledOrders.reasons[reason] = (dayStats.canceledOrders.reasons[reason] || 0) + 1; dailyStats.set(dateKey, dayStats); } } // Set peak hour stats if (maxHourCount > 0) { stats.peakOrderHour = { hour: peakHour, count: maxHourCount, displayHour: this._formatHour(peakHour), hourData: stats.hourlyOrders.map((count, hour) => ({ hour, displayHour: this._formatHour(hour), count, percentage: (count / stats.orderCount) * 100 })) }; } // Set best day stats if (bestDay.date) { stats.bestRevenueDay = { ...bestDay, dialogType: 'revenue', // Add dialog type for frontend dialogTitle: 'Revenue Details' // Add dialog title for frontend }; } // Process products with status tracking stats.products.list = stats.products.list.map(product => ({ ...product, averageOrderValue: product.totalRevenue / product.orderCount, orders: Array.from(product.orders) })); stats.products.list.sort((a, b) => b.totalRevenue - a.totalRevenue); stats.products.total = stats.products.list.length; // Format brands with more detail and period-specific data stats.brands.list = Array.from(brandMap.values()) .map(brand => { const brandStats = { name: brand.name, count: brand.quantity, revenue: brand.revenue, productCount: brand.products.size, percentage: (brand.revenue / stats.revenue) * 100, // Calculate percentage based on period revenue averageOrderValue: brand.revenue / brand.quantity, products: Array.from(brand.products) }; // Add display formatting for charts brandStats.tooltipLabel = `${brand.name}\nRevenue: $${brand.revenue.toFixed(2)}\nItems: ${brand.quantity}`; return brandStats; }) .sort((a, b) => b.revenue - a.revenue); stats.brands.topBrands = stats.brands.list.slice(0, 10); stats.brands.totalRevenue = stats.revenue; // Use period revenue stats.brands.averageOrderValue = stats.revenue / stats.itemCount; stats.brands.total = stats.brands.list.length; // Format categories with more detail and period-specific data stats.categories.list = Array.from(categoryMap.values()) .map(category => { const categoryStats = { name: category.name, count: category.quantity, revenue: category.revenue, productCount: category.products.size, percentage: (category.revenue / stats.revenue) * 100, // Calculate percentage based on period revenue averageOrderValue: category.revenue / category.quantity, products: Array.from(category.products) }; // Add display formatting for charts categoryStats.tooltipLabel = `${category.name}\nRevenue: $${category.revenue.toFixed(2)}\nItems: ${category.quantity}`; return categoryStats; }) .sort((a, b) => b.revenue - a.revenue); stats.categories.topCategories = stats.categories.list.slice(0, 10); stats.categories.totalRevenue = stats.revenue; // Use period revenue stats.categories.averageOrderValue = stats.revenue / stats.itemCount; stats.categories.total = stats.categories.list.length; // Remove pie chart labels and create a key stats.brands.key = stats.brands.list.map(brand => ({ name: brand.name, color: this._getColorForBrand(brand.name) })); stats.categories.key = stats.categories.list.map(category => ({ name: category.name, color: this._getColorForCategory(category.name) })); // Set peak hour stats with proper formatting if (maxHourCount > 0) { stats.peakOrderHour = { hour: peakHour, count: maxHourCount, displayHour: this._formatHour(peakHour), hourData: stats.hourlyOrders.map((count, hour) => ({ hour, displayHour: this._formatHour(hour), count, percentage: (count / stats.orderCount) * 100 })) }; } // Set best day stats with link to revenue dialog if (bestDay.date) { stats.bestRevenueDay = { ...bestDay, dialogType: 'revenue', // Add dialog type for frontend dialogTitle: 'Revenue Details' // Add dialog title for frontend }; } // Add daily stats for order types const orderTypeStats = { preOrders: { dailyData: [] }, localPickup: { dailyData: [] }, heldItems: { dailyData: [] } }; // Process daily stats for order types stats.dailyData.forEach(day => { // Pre-orders daily data orderTypeStats.preOrders.dailyData.push({ date: day.date, count: day.orderTypes.preOrders.count, value: day.orderTypes.preOrders.value, percentage: day.orderTypes.preOrders.percentage, totalOrders: day.orders }); // Local pickup daily data orderTypeStats.localPickup.dailyData.push({ date: day.date, count: day.orderTypes.localPickup.count, value: day.orderTypes.localPickup.value, percentage: day.orderTypes.localPickup.percentage, totalOrders: day.orders }); // Held items daily data orderTypeStats.heldItems.dailyData.push({ date: day.date, count: day.orderTypes.heldItems.count, value: day.orderTypes.heldItems.value, percentage: day.orderTypes.heldItems.percentage, totalOrders: day.orders }); }); // Add order type stats to main stats object stats.orderTypes.preOrders.dailyData = orderTypeStats.preOrders.dailyData; stats.orderTypes.localPickup.dailyData = orderTypeStats.localPickup.dailyData; stats.orderTypes.heldItems.dailyData = orderTypeStats.heldItems.dailyData; // Convert daily stats to array and sort stats.dailyData = Array.from(dailyStats.values()) .sort((a, b) => a.date.localeCompare(b.date)) .map(day => ({ date: day.date, timestamp: day.timestamp, revenue: Number(day.revenue || 0), orders: Number(day.orders || 0), itemCount: Number(day.itemCount || 0), averageOrderValue: Number(day.averageOrderValue || 0), averageItemsPerOrder: Number(day.averageItemsPerOrder || 0), hourlyOrders: day.hourlyOrders || Array(24).fill(0), refunds: { total: Number(day.refunds?.total || 0), count: Number(day.refunds?.count || 0), reasons: day.refunds?.reasons || {} }, canceledOrders: { total: Number(day.canceledOrders?.total || 0), count: Number(day.canceledOrders?.count || 0), reasons: day.canceledOrders?.reasons || {} }, orderTypes: { preOrders: { count: Number(day.orderTypes?.preOrders?.count || 0), value: Number(day.orderTypes?.preOrders?.value || 0), percentage: Number(day.orderTypes?.preOrders?.percentage || 0), averageValue: Number(day.orderTypes?.preOrders?.averageValue || 0) }, localPickup: { count: Number(day.orderTypes?.localPickup?.count || 0), value: Number(day.orderTypes?.localPickup?.value || 0), percentage: Number(day.orderTypes?.localPickup?.percentage || 0), averageValue: Number(day.orderTypes?.localPickup?.averageValue || 0) }, heldItems: { count: Number(day.orderTypes?.heldItems?.count || 0), value: Number(day.orderTypes?.heldItems?.value || 0), percentage: Number(day.orderTypes?.heldItems?.percentage || 0), averageValue: Number(day.orderTypes?.heldItems?.averageValue || 0) }, digital: { count: Number(day.orderTypes?.digital?.count || 0), value: Number(day.orderTypes?.digital?.value || 0), percentage: Number(day.orderTypes?.digital?.percentage || 0), averageValue: Number(day.orderTypes?.digital?.averageValue || 0) }, giftCard: { count: Number(day.orderTypes?.giftCard?.count || 0), value: Number(day.orderTypes?.giftCard?.value || 0), percentage: Number(day.orderTypes?.giftCard?.percentage || 0), averageValue: Number(day.orderTypes?.giftCard?.averageValue || 0) } } })); // Set totals stats.brands.total = stats.brands.list.length; stats.categories.total = stats.categories.list.length; return stats; } catch (error) { console.error('[EventsService] Error calculating period stats:', error); throw error; } } _getEmptyStats() { return { orderCount: 0, revenue: 0, averageOrderValue: 0, itemCount: 0, prevPeriodRevenue: 0, projectedRevenue: 0, periodProgress: 0, dailyData: [], products: { list: [], total: 0, status: { backordered: 0, inStock: 0, outOfStock: 0, preorder: 0 } }, shipping: { shippedCount: 0, locations: { total: 0, byState: [], byCountry: [] }, methods: {}, methodStats: [], methodPercentages: {} }, refunds: { total: 0, count: 0, reasons: {}, items: [], averageAmount: 0 }, canceledOrders: { total: 0, count: 0, reasons: {}, items: [], averageAmount: 0 }, orderTypes: { preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }, giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 } }, brands: { total: 0, list: [], topBrands: [], totalRevenue: 0, averageOrderValue: 0 }, categories: { total: 0, list: [], topCategories: [], totalRevenue: 0, averageOrderValue: 0 }, hourlyOrders: Array(24).fill(0), peakOrderHour: null, bestRevenueDay: null, orderValueRange: { largest: 0, smallest: 0, largestOrderId: null, smallestOrderId: null, distribution: { under25: { count: 0, total: 0 }, under50: { count: 0, total: 0 }, under100: { count: 0, total: 0 }, under200: { count: 0, total: 0 }, over200: { count: 0, total: 0 } } } }; } async getMultiMetricEvents(params = {}) { try { const { timeRange, startDate, endDate, metricIds } = params; const metrics = metricIds || Object.values(METRIC_IDS); // Try to get from cache first const cacheKey = this.redisService._getCacheKey('events', params); let cachedData = null; try { cachedData = await this.redisService.get(`${cacheKey}:feed`); if (cachedData) { return cachedData; } } catch (cacheError) { console.warn('[EventsService] Cache error:', cacheError); } // Fetch events for all specified metrics const eventPromises = metrics.map(metricId => this.getEvents({ startDate, endDate, timeRange, metricId, sort: '-datetime' }) ); const results = await Promise.all(eventPromises); // Combine and sort all events const allEvents = results .flatMap(result => result.data || []) .sort((a, b) => new Date(b.attributes?.datetime) - new Date(a.attributes?.datetime)); // Transform the events const transformedEvents = this._transformEvents(allEvents); const result = { data: transformedEvents, meta: { total_count: transformedEvents.length } }; // Cache the results try { const ttl = this.redisService._getTTL(timeRange); await this.redisService.set(`${cacheKey}:feed`, result, ttl); } catch (cacheError) { console.warn('[EventsService] Cache set error:', cacheError); } return result; } catch (error) { console.error('[EventsService] Error fetching multi-metric events:', error); throw error; } } _transformEvents(events) { if (!Array.isArray(events)) { console.warn('[EventsService] Events is not an array:', events); return []; } return events.map(event => { try { // Extract metric ID from the relationships field if it exists const metricId = event.relationships?.metric?.data?.id || event.attributes?.metric?.id || event.attributes?.metric_id; // Extract properties from all possible locations in the Klaviyo event structure const eventProps = { ...(event.attributes?.event_properties || {}), ...(event.attributes?.properties || {}), ...(event.attributes?.profile || {}), value: event.attributes?.value, datetime: event.attributes?.datetime }; // Normalize shipping data const shippingData = { name: eventProps.ShippingName || eventProps.shipping_name || eventProps.shipping?.name, street1: eventProps.ShippingStreet1 || eventProps.shipping_street1 || eventProps.shipping?.street1, street2: eventProps.ShippingStreet2 || eventProps.shipping_street2 || eventProps.shipping?.street2, city: eventProps.ShippingCity || eventProps.shipping_city || eventProps.shipping?.city, state: eventProps.ShippingState || eventProps.shipping_state || eventProps.shipping?.state, zip: eventProps.ShippingZip || eventProps.shipping_zip || eventProps.shipping?.zip, country: eventProps.ShippingCountry || eventProps.shipping_country || eventProps.shipping?.country, method: eventProps.ShipMethod || eventProps.shipping_method || eventProps.shipping?.method, tracking: eventProps.TrackingNumber || eventProps.tracking_number }; const transformed = { id: event.id, type: event.type, metric_id: metricId, // Preserve the original attributes structure for compatibility attributes: { ...event.attributes, datetime: event.attributes?.datetime, value: event.attributes?.value, metric: { ...event.attributes?.metric, id: metricId } }, relationships: event.relationships, event_properties: { ...eventProps, // Transform common properties EmailAddress: eventProps.EmailAddress || eventProps.email, FirstName: eventProps.FirstName || eventProps.first_name, LastName: eventProps.LastName || eventProps.last_name, OrderId: eventProps.OrderId || eventProps.FromOrder || eventProps.order_id, TotalAmount: Number(eventProps.TotalAmount || eventProps.PaymentAmount || eventProps.total_amount || eventProps.value || 0), Items: this._transformItems(eventProps.Items || eventProps.items || eventProps.line_items || []), // Add normalized shipping information ShippingName: shippingData.name, ShippingStreet1: shippingData.street1, ShippingStreet2: shippingData.street2, ShippingCity: shippingData.city, ShippingState: shippingData.state, ShippingZip: shippingData.zip, ShippingCountry: shippingData.country, ShippingMethod: shippingData.method, TrackingNumber: shippingData.tracking, ShipMethod: shippingData.method, // Add payment information PaymentMethod: eventProps.PaymentMethod || eventProps.payment_method || eventProps.payment?.method, PaymentName: eventProps.PaymentName || eventProps.payment_name || eventProps.payment?.name, PaymentAmount: Number(eventProps.PaymentAmount || eventProps.payment_amount || eventProps.payment?.amount || 0), // Add order flags OrderType: eventProps.OrderType || eventProps.order_type || 'standard', HasPreorder: Boolean(eventProps.HasPreorder || eventProps.has_preorder || eventProps.preorder), LocalPickup: Boolean(eventProps.LocalPickup || eventProps.local_pickup || eventProps.pickup), IsOnHold: Boolean(eventProps.IsOnHold || eventProps.is_on_hold || eventProps.on_hold), HasDigiItem: Boolean(eventProps.HasDigiItem || eventProps.has_digital_item || eventProps.digital_item), HasNotions: Boolean(eventProps.HasNotions || eventProps.has_notions || eventProps.notions), HasDigitalGC: Boolean(eventProps.HasDigitalGC || eventProps.has_digital_gc || eventProps.gift_card), StillOwes: Boolean(eventProps.StillOwes || eventProps.still_owes || eventProps.balance_due), // Add refund/cancel information CancelReason: eventProps.CancelReason || eventProps.cancel_reason || eventProps.reason, CancelMessage: eventProps.CancelMessage || eventProps.cancel_message || eventProps.message, OrderMessage: eventProps.OrderMessage || eventProps.order_message || eventProps.note } }; return transformed; } catch (error) { console.error('[EventsService] Error transforming event:', error, event); // Return a minimal valid event structure return { id: event.id || 'unknown', type: event.type || 'unknown', metric_id: event.relationships?.metric?.data?.id || 'unknown', attributes: event.attributes || {}, event_properties: {} }; } }).filter(Boolean); // Remove any null/undefined events } _transformItems(items) { if (!Array.isArray(items)) { console.warn('[EventsService] Items is not an array:', items); return []; } return items.map(item => { const transformed = { ...item, ProductID: item.ProductID || item.product_id, ProductName: item.ProductName || item.product_name, SKU: item.SKU || item.sku, Brand: item.Brand || item.brand, Categories: item.Categories || item.categories || [], ItemPrice: Number(item.ItemPrice || item.item_price || 0), Quantity: Number(item.Quantity || item.QuantityOrdered || item.quantity || 1), QuantityOrdered: Number(item.QuantityOrdered || item.Quantity || item.quantity_ordered || 1), QuantitySent: Number(item.QuantitySent || item.quantity_sent || 0), QuantityBackordered: Number(item.QuantityBackordered || item.quantity_backordered || 0), RowTotal: Number(item.RowTotal || item.row_total || (item.ItemPrice * (item.Quantity || item.QuantityOrdered || 1))), ItemStatus: item.ItemStatus || item.item_status || 'In Stock', ImgThumb: item.ImgThumb || item.img_thumb }; return transformed; }); } _formatHour(hour) { if (hour === 0) return "12:00 AM"; if (hour === 12) return "12:00 PM"; if (hour > 12) return `${hour - 12}:00 PM`; return `${hour}:00 AM`; } async calculateDetailedStats(params = {}) { try { const { metric, daily = false } = params; console.log('[EventsService] Request params:', params); // Get period dates let periodStart, periodEnd, prevPeriodStart, prevPeriodEnd; if (params.startDate && params.endDate) { periodStart = this.timeManager.toDateTime(params.startDate); periodEnd = this.timeManager.toDateTime(params.endDate); const duration = periodEnd.diff(periodStart); prevPeriodEnd = periodStart.minus({ milliseconds: 1 }); prevPeriodStart = prevPeriodEnd.minus(duration); } else if (params.timeRange) { // Handle both current and previous period time ranges const timeRange = params.timeRange; const isPreviousPeriod = timeRange.startsWith('previous'); const normalizedTimeRange = isPreviousPeriod ? timeRange.replace('previous', 'last') : timeRange; console.log('[EventsService] Time range details:', { originalTimeRange: timeRange, isPreviousPeriod, normalizedTimeRange }); // Get current period range const range = this.timeManager.getDateRange(normalizedTimeRange); if (!range) { throw new Error(`Invalid time range specified: ${timeRange}`); } // Get previous period range using TimeManager const prevRange = this.timeManager.getPreviousPeriod(normalizedTimeRange); if (!prevRange) { throw new Error(`Could not calculate previous period for: ${timeRange}`); } periodStart = range.start; periodEnd = range.end; prevPeriodStart = prevRange.start; prevPeriodEnd = prevRange.end; console.log('[EventsService] Calculated date ranges:', { timeRange, current: { start: periodStart.toISO(), end: periodEnd.toISO(), duration: periodEnd.diff(periodStart).as('days') }, previous: { start: prevPeriodStart.toISO(), end: prevPeriodEnd.toISO(), duration: prevPeriodEnd.diff(prevPeriodStart).as('days') } }); } // Load both current and previous period data console.log('[EventsService] Fetching events with params:', { current: { startDate: periodStart.toISO(), endDate: periodEnd.toISO(), metricId: METRIC_IDS.PLACED_ORDER, ...params }, previous: { startDate: prevPeriodStart.toISO(), endDate: prevPeriodEnd.toISO(), metricId: METRIC_IDS.PLACED_ORDER } }); const [currentResponse, prevResponse] = await Promise.all([ this.getEvents({ ...params, startDate: periodStart.toISO(), endDate: periodEnd.toISO(), metricId: METRIC_IDS.PLACED_ORDER }), this.getEvents({ metricId: METRIC_IDS.PLACED_ORDER, startDate: prevPeriodStart.toISO(), endDate: prevPeriodEnd.toISO() }) ]); // Transform events const currentEvents = this._transformEvents(currentResponse.data || []); const prevEvents = this._transformEvents(prevResponse.data || []); console.log('[EventsService] Transformed events:', { current: { count: currentEvents.length, revenue: currentEvents.reduce((sum, event) => sum + (Number(event.event_properties?.TotalAmount) || 0), 0) }, previous: { count: prevEvents.length, revenue: prevEvents.reduce((sum, event) => sum + (Number(event.event_properties?.TotalAmount) || 0), 0) } }); // Initialize daily stats map with all dates in range const dailyStats = new Map(); let currentDate = periodStart; while (currentDate <= periodEnd) { const dateKey = currentDate.toFormat('yyyy-MM-dd'); dailyStats.set(dateKey, { date: currentDate.toISO(), timestamp: dateKey, revenue: 0, orders: 0, itemCount: 0, averageOrderValue: 0, averageItemsPerOrder: 0, prevRevenue: 0, prevOrders: 0, prevItemCount: 0, prevAvgOrderValue: 0 }); currentDate = currentDate.plus({ days: 1 }); } // Process current period events for (const event of currentEvents) { const datetime = this.timeManager.toDateTime(event.attributes?.datetime); if (!datetime) continue; const dateKey = datetime.toFormat('yyyy-MM-dd'); if (!dailyStats.has(dateKey)) continue; const dayStats = dailyStats.get(dateKey); const props = event.event_properties || {}; const totalAmount = Number(props.TotalAmount || 0); const items = props.Items || []; dayStats.revenue += totalAmount; dayStats.orders++; dayStats.itemCount += items.length; dayStats.averageOrderValue = dayStats.revenue / dayStats.orders; dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.orders; } // Process previous period events const prevDailyStats = new Map(); let prevDate = prevPeriodStart; while (prevDate <= prevPeriodEnd) { const dateKey = prevDate.toFormat('yyyy-MM-dd'); prevDailyStats.set(dateKey, { date: prevDate.toISO(), revenue: 0, orders: 0, itemCount: 0 }); prevDate = prevDate.plus({ days: 1 }); } // Aggregate previous period data for (const event of prevEvents) { const datetime = this.timeManager.toDateTime(event.attributes?.datetime); if (!datetime) continue; const dateKey = datetime.toFormat('yyyy-MM-dd'); if (!prevDailyStats.has(dateKey)) continue; const dayStats = prevDailyStats.get(dateKey); const props = event.event_properties || {}; const totalAmount = Number(props.TotalAmount || 0); const items = props.Items || []; dayStats.revenue += totalAmount; dayStats.orders++; dayStats.itemCount += items.length; } // Map previous period data to current period days based on relative position const prevPeriodDays = Array.from(prevDailyStats.values()); const currentPeriodDays = Array.from(dailyStats.values()); const daysInPeriod = currentPeriodDays.length; for (let i = 0; i < daysInPeriod; i++) { const currentDayStats = currentPeriodDays[i]; const prevDayStats = prevPeriodDays[i]; if (prevDayStats) { const dayStats = dailyStats.get(currentDayStats.timestamp); dayStats.prevRevenue = prevDayStats.revenue; dayStats.prevOrders = prevDayStats.orders; dayStats.prevItemCount = prevDayStats.itemCount; dayStats.prevAvgOrderValue = prevDayStats.orders > 0 ? prevDayStats.revenue / prevDayStats.orders : 0; } } // Log the final daily stats before returning console.log('[EventsService] Final daily stats sample:', { totalDays: dailyStats.size, firstDay: Array.from(dailyStats.values())[0], lastDay: Array.from(dailyStats.values())[dailyStats.size - 1] }); // Convert to array and sort by date const stats = Array.from(dailyStats.values()) .sort((a, b) => a.date.localeCompare(b.date)) .map(day => ({ ...day, revenue: Number(day.revenue || 0), orders: Number(day.orders || 0), itemCount: Number(day.itemCount || 0), averageOrderValue: Number(day.averageOrderValue || 0), averageItemsPerOrder: Number(day.averageItemsPerOrder || 0), prevRevenue: Number(day.prevRevenue || 0), prevOrders: Number(day.prevOrders || 0), prevItemCount: Number(day.prevItemCount || 0), prevAvgOrderValue: Number(day.prevAvgOrderValue || 0) })); return stats; } catch (error) { console.error('[EventsService] Error calculating detailed stats:', error); throw error; } } _getColorForBrand(brandName) { // Generate a consistent color based on the brand name let hash = 0; for (let i = 0; i < brandName.length; i++) { hash = brandName.charCodeAt(i) + ((hash << 5) - hash); } // Use HSL to ensure colors are visually distinct and pleasing const hue = Math.abs(hash % 360); return `hsl(${hue}, 70%, 50%)`; } _getColorForCategory(categoryName) { // Generate a consistent color based on the category name let hash = 0; for (let i = 0; i < categoryName.length; i++) { hash = categoryName.charCodeAt(i) + ((hash << 5) - hash); } // Use HSL with different saturation/lightness than brands const hue = Math.abs(hash % 360); return `hsl(${hue}, 60%, 60%)`; } async getBatchMetrics(params = {}) { try { const { timeRange, startDate, endDate, metrics = [] } = params; // Create a map of all metric requests const metricPromises = metrics.map(metric => { switch(metric) { case 'orders': return this.getEvents({ ...params, metricId: METRIC_IDS.PLACED_ORDER }); case 'revenue': return this.getEvents({ ...params, metricId: METRIC_IDS.PLACED_ORDER, property: 'TotalAmount' }); case 'refunds': return this.getEvents({ ...params, metricId: METRIC_IDS.PAYMENT_REFUNDED }); case 'cancellations': return this.getEvents({ ...params, metricId: METRIC_IDS.CANCELED_ORDER }); case 'shipping': return this.getEvents({ ...params, metricId: METRIC_IDS.SHIPPED_ORDER }); default: return Promise.resolve(null); } }); // Execute all promises in parallel const results = await Promise.all(metricPromises); // Transform results into a keyed object const batchResults = {}; metrics.forEach((metric, index) => { batchResults[metric] = results[index]; }); return batchResults; } catch (error) { console.error('[EventsService] Error in batch metrics:', error); throw error; } } }