import fetch from 'node-fetch'; import { TimeManager } from '../utils/time.utils.js'; import { RedisService } from './redis.service.js'; import _ from 'lodash'; 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.getDayStart(this.timeManager.toDateTime(params.startDate)); periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(params.endDate)); const duration = periodEnd.diff(periodStart); prevPeriodStart = this.timeManager.getDayStart(periodStart.minus(duration)); prevPeriodEnd = this.timeManager.getDayEnd(periodStart.minus({ milliseconds: 1 })); } else if (params.timeRange) { const range = this.timeManager.getDateRange(params.timeRange); periodStart = range.start; periodEnd = range.end; const prevRange = this.timeManager.getPreviousPeriod(params.timeRange); prevPeriodStart = prevRange.start; prevPeriodEnd = prevRange.end; } // 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({ // Only pass through non-date related params for previous period ..._.omit(params, ['timeRange', 'startDate', 'endDate']), metricId: METRIC_IDS.PLACED_ORDER, startDate: prevPeriodStart.toISO(), endDate: prevPeriodEnd.toISO() }) ]); // Add debug logging console.log('[EventsService] Previous period request:', { params: _.omit(params, ['timeRange', 'startDate', 'endDate']), dates: { start: prevPeriodStart.toISO(), end: prevPeriodEnd.toISO() }, responseLength: prevPeriodData?.data?.length }); // 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 = this.timeManager.getDayStart(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 dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.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: dayStart.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 value distribution if (totalAmount < 25) { dayStats.orderValueRange.distribution.under25.count++; dayStats.orderValueRange.distribution.under25.total += totalAmount; } else if (totalAmount < 50) { dayStats.orderValueRange.distribution.under50.count++; dayStats.orderValueRange.distribution.under50.total += totalAmount; } else if (totalAmount < 100) { dayStats.orderValueRange.distribution.under100.count++; dayStats.orderValueRange.distribution.under100.total += totalAmount; } else if (totalAmount < 200) { dayStats.orderValueRange.distribution.under200.count++; dayStats.orderValueRange.distribution.under200.total += totalAmount; } else { dayStats.orderValueRange.distribution.over200.count++; dayStats.orderValueRange.distribution.over200.total += totalAmount; } } // 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 dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.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 dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.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); // Get period dates using TimeManager to respect 1 AM day start let periodStart, periodEnd; if (startDate && endDate) { periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(startDate)); periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(endDate)); } else if (timeRange) { const range = this.timeManager.getDateRange(timeRange); periodStart = range.start; periodEnd = range.end; } // 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: periodStart.toISO(), endDate: periodEnd.toISO(), timeRange, metricId, sort: '-datetime' }) ); const results = await Promise.all(eventPromises); // Transform and flatten the events into a single array const allEvents = []; results.forEach((result) => { if (result && Array.isArray(result.data)) { allEvents.push(...result.data); } }); // Sort all events by datetime in descending order allEvents.sort((a, b) => { const dateA = new Date(a.attributes?.datetime || 0); const dateB = new Date(b.attributes?.datetime || 0); return dateB - dateA; }); const result = { data: allEvents, meta: { total_count: allEvents.length } }; // Cache the result 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 in batch metrics:', 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 all possible locations const metricId = event.relationships?.metric?.data?.id || event.attributes?.metric?.id || event.attributes?.metric_id; // Extract properties from all possible locations const rawProps = { ...(event.attributes?.event_properties || {}), ...(event.attributes?.properties || {}), ...(event.attributes?.profile || {}), value: event.attributes?.value, datetime: event.attributes?.datetime }; // Normalize shipping data const shippingData = { name: rawProps.ShippingName || rawProps.shipping_name || rawProps.shipping?.name, street1: rawProps.ShippingStreet1 || rawProps.shipping_street1 || rawProps.shipping?.street1, street2: rawProps.ShippingStreet2 || rawProps.shipping_street2 || rawProps.shipping?.street2, city: rawProps.ShippingCity || rawProps.shipping_city || rawProps.shipping?.city, state: rawProps.ShippingState || rawProps.shipping_state || rawProps.shipping?.state, zip: rawProps.ShippingZip || rawProps.shipping_zip || rawProps.shipping?.zip, country: rawProps.ShippingCountry || rawProps.shipping_country || rawProps.shipping?.country, method: rawProps.ShipMethod || rawProps.shipping_method || rawProps.shipping?.method, tracking: rawProps.TrackingNumber || rawProps.tracking_number }; // Normalize payment data const paymentData = { method: rawProps.PaymentMethod || rawProps.payment_method || rawProps.payment?.method, name: rawProps.PaymentName || rawProps.payment_name || rawProps.payment?.name, amount: Number(rawProps.PaymentAmount || rawProps.payment_amount || rawProps.payment?.amount || 0) }; // Normalize order flags const orderFlags = { type: rawProps.OrderType || rawProps.order_type || 'standard', hasPreorder: Boolean(rawProps.HasPreorder || rawProps.has_preorder || rawProps.preorder), localPickup: Boolean(rawProps.LocalPickup || rawProps.local_pickup || rawProps.pickup), isOnHold: Boolean(rawProps.IsOnHold || rawProps.is_on_hold || rawProps.on_hold), hasDigiItem: Boolean(rawProps.HasDigiItem || rawProps.has_digital_item || rawProps.digital_item), hasNotions: Boolean(rawProps.HasNotions || rawProps.has_notions || rawProps.notions), hasDigitalGC: Boolean(rawProps.HasDigitalGC || rawProps.has_digital_gc || rawProps.gift_card), stillOwes: Boolean(rawProps.StillOwes || rawProps.still_owes || rawProps.balance_due) }; // Normalize refund/cancel data const refundData = { reason: rawProps.CancelReason || rawProps.cancel_reason || rawProps.reason, message: rawProps.CancelMessage || rawProps.cancel_message || rawProps.message, orderMessage: rawProps.OrderMessage || rawProps.order_message || rawProps.note }; // Transform items const items = this._transformItems(rawProps.Items || rawProps.items || rawProps.line_items || []); // Calculate totals const totalAmount = Number(rawProps.TotalAmount || rawProps.PaymentAmount || rawProps.total_amount || rawProps.value || 0); const itemCount = items.reduce((sum, item) => sum + Number(item.Quantity || item.QuantityOrdered || 1), 0); const transformed = { id: event.id, type: event.type, metric_id: metricId, attributes: { ...event.attributes, datetime: event.attributes?.datetime, value: event.attributes?.value, metric: { ...event.attributes?.metric, id: metricId } }, relationships: event.relationships, event_properties: { // Basic properties EmailAddress: rawProps.EmailAddress || rawProps.email, FirstName: rawProps.FirstName || rawProps.first_name, LastName: rawProps.LastName || rawProps.last_name, OrderId: rawProps.OrderId || rawProps.FromOrder || rawProps.order_id, TotalAmount: totalAmount, ItemCount: itemCount, Items: items, // Shipping information ...shippingData, // Payment information ...paymentData, // Order flags ...orderFlags, // Refund/cancel information ...refundData, // Original properties (for backward compatibility) ...rawProps } }; 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 => { try { const quantity = Number(item.Quantity || item.QuantityOrdered || item.quantity || item.quantity_ordered || 1); const price = Number(item.ItemPrice || item.item_price || item.price || 0); const rowTotal = Number(item.RowTotal || item.row_total || (price * quantity) || 0); const transformed = { // Basic item information ProductID: item.ProductID || item.product_id || item.id, ProductName: item.ProductName || item.product_name || item.name, SKU: item.SKU || item.sku, Brand: item.Brand || item.brand, Categories: Array.isArray(item.Categories) ? item.Categories : Array.isArray(item.categories) ? item.categories : [], // Pricing ItemPrice: price, RowTotal: rowTotal, // Quantities Quantity: quantity, QuantityOrdered: quantity, QuantitySent: Number(item.QuantitySent || item.quantity_sent || 0), QuantityBackordered: Number(item.QuantityBackordered || item.quantity_backordered || 0), // Status and images ItemStatus: item.ItemStatus || item.item_status || item.status || 'In Stock', ImgThumb: item.ImgThumb || item.img_thumb || item.thumbnail, // Additional properties IsPreorder: Boolean(item.IsPreorder || item.is_preorder || item.preorder), IsDigital: Boolean(item.IsDigital || item.is_digital || item.digital), IsGiftCard: Boolean(item.IsGiftCard || item.is_gift_card || item.gift_card), // Original properties (for backward compatibility) ...item }; return transformed; } catch (error) { console.error('[EventsService] Error transforming item:', error, item); // Return a minimal valid item structure return { ProductID: item.ProductID || item.product_id || 'unknown', ProductName: item.ProductName || item.product_name || 'Unknown Product', Quantity: 1, ItemPrice: 0, RowTotal: 0 }; } }); } _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 using TimeManager to respect 1 AM day start let periodStart, periodEnd, prevPeriodStart, prevPeriodEnd; if (params.startDate && params.endDate) { periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(params.startDate)); periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(params.endDate)); const duration = periodEnd.diff(periodStart); prevPeriodStart = this.timeManager.getDayStart(periodStart.minus(duration)); prevPeriodEnd = this.timeManager.getDayEnd(periodStart.minus({ milliseconds: 1 })); } else if (params.timeRange) { const range = this.timeManager.getDateRange(params.timeRange); periodStart = range.start; periodEnd = range.end; const prevRange = this.timeManager.getPreviousPeriod(params.timeRange); prevPeriodStart = prevRange.start; prevPeriodEnd = prevRange.end; } // For order range, we need to process all orders with their value distribution if (metric === 'order_range') { const [currentEvents] = await Promise.all([ this.getEvents({ ...params, startDate: periodStart.toISO(), endDate: periodEnd.toISO(), metricId: METRIC_IDS.PLACED_ORDER }) ]); // Transform events const transformedEvents = this._transformEvents(currentEvents.data || []); console.log(`[EventsService] Processing ${transformedEvents.length} orders for order range`); // 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, orders: 0, averageOrderValue: 0, orderValueRange: { largest: 0, smallest: 0, largestOrderId: null, smallestOrderId: null }, orderValueDistribution: [ { min: 0, max: 25, count: 0, total: 0 }, { min: 25, max: 50, count: 0, total: 0 }, { min: 50, max: 100, count: 0, total: 0 }, { min: 100, max: 200, count: 0, total: 0 }, { min: 200, max: 'Infinity', count: 0, total: 0 } ] }); currentDate = currentDate.plus({ days: 1 }); } // Process events for (const event of transformedEvents) { const datetime = this.timeManager.toDateTime(event.attributes?.datetime); if (!datetime) continue; const props = event.event_properties || {}; const totalAmount = Number(props.TotalAmount || 0); const orderId = props.OrderId; const dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.toFormat('yyyy-MM-dd'); if (dailyStats.has(dateKey)) { const dayStats = dailyStats.get(dateKey); dayStats.orders++; // Update order value range if (totalAmount > dayStats.orderValueRange.largest) { dayStats.orderValueRange.largest = totalAmount; dayStats.orderValueRange.largestOrderId = orderId; } if (totalAmount > 0 && (dayStats.orderValueRange.smallest === 0 || totalAmount < dayStats.orderValueRange.smallest)) { dayStats.orderValueRange.smallest = totalAmount; dayStats.orderValueRange.smallestOrderId = orderId; } // Update distribution if (totalAmount < 25) { dayStats.orderValueDistribution[0].count++; dayStats.orderValueDistribution[0].total += totalAmount; } else if (totalAmount < 50) { dayStats.orderValueDistribution[1].count++; dayStats.orderValueDistribution[1].total += totalAmount; } else if (totalAmount < 100) { dayStats.orderValueDistribution[2].count++; dayStats.orderValueDistribution[2].total += totalAmount; } else if (totalAmount < 200) { dayStats.orderValueDistribution[3].count++; dayStats.orderValueDistribution[3].total += totalAmount; } else { dayStats.orderValueDistribution[4].count++; dayStats.orderValueDistribution[4].total += totalAmount; } dayStats.averageOrderValue = dayStats.orderValueDistribution.reduce((sum, range) => sum + range.total, 0) / dayStats.orders; dailyStats.set(dateKey, dayStats); } } // Convert to array and sort by date const stats = Array.from(dailyStats.values()) .sort((a, b) => a.date.localeCompare(b.date)); return stats; } // For refunds and cancellations, we need to fetch those specific events if (metric === 'refunds' || metric === 'cancellations') { const [currentEvents] = await Promise.all([ this.getEvents({ ...params, startDate: periodStart.toISO(), endDate: periodEnd.toISO(), metricId: metric === 'refunds' ? METRIC_IDS.PAYMENT_REFUNDED : METRIC_IDS.CANCELED_ORDER }) ]); // Transform events const transformedEvents = this._transformEvents(currentEvents.data || []); console.log(`[EventsService] Processing ${transformedEvents.length} ${metric}`); // Initialize daily stats map with all dates in range using TimeManager's day start 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, total: 0, count: 0, reasons: {}, items: [] }); currentDate = this.timeManager.getDayStart(currentDate.plus({ days: 1 })); } // Aggregate all reasons and items for the entire period const periodStats = { total: 0, count: 0, reasons: {}, items: [] }; // Process current period events for (const event of transformedEvents) { const datetime = this.timeManager.toDateTime(event.attributes?.datetime); if (!datetime) continue; const props = event.event_properties || {}; const amount = Number(metric === 'refunds' ? props.PaymentAmount : props.TotalAmount || 0); const reason = props.CancelReason || props.OrderMessage || 'No reason provided'; const orderId = props.OrderId || props.FromOrder; const item = { orderId, amount, reason, datetime: datetime.toISO() }; // Always update period totals for events within the period periodStats.total += amount; periodStats.count++; periodStats.reasons[reason] = (periodStats.reasons[reason] || 0) + 1; periodStats.items.push(item); // Get the day start for this event's time const dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.toFormat('yyyy-MM-dd'); // Update daily stats if we have this day in our map if (dailyStats.has(dateKey)) { const dayStats = dailyStats.get(dateKey); dayStats.total += amount; dayStats.count++; dayStats.reasons[reason] = (dayStats.reasons[reason] || 0) + 1; dayStats.items.push(item); dailyStats.set(dateKey, dayStats); } } console.log(`[EventsService] Period stats for ${metric}:`, { total: periodStats.total, count: periodStats.count, reasonCount: Object.keys(periodStats.reasons).length, itemCount: periodStats.items.length }); // Convert to array and sort by date const stats = Array.from(dailyStats.values()) .sort((a, b) => a.date.localeCompare(b.date)) .map(day => ({ ...day, [metric === 'refunds' ? 'refunds' : 'canceledOrders']: { total: day.total, count: day.count, reasons: periodStats.reasons, // Use period-wide reasons for each day items: day.items, periodTotal: periodStats.total, periodCount: periodStats.count, periodReasons: periodStats.reasons, periodItems: periodStats.items } })); return stats; } // For other metrics, continue with existing logic const [currentResponse, prevResponse] = await Promise.all([ this.getEvents({ ...params, startDate: periodStart.toISO(), endDate: periodEnd.toISO(), metricId: METRIC_IDS.PLACED_ORDER }), this.getEvents({ ..._.omit(params, ['timeRange', 'startDate', 'endDate']), startDate: prevPeriodStart.toISO(), endDate: prevPeriodEnd.toISO(), metricId: METRIC_IDS.PLACED_ORDER }) ]); // Transform events const currentEvents = this._transformEvents(currentResponse.data || []); const prevEvents = this._transformEvents(prevResponse.data || []); // Initialize daily stats map with all dates in range using TimeManager's day start 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, count: 0, value: 0, percentage: 0, totalOrders: 0, prevValue: 0, prevOrders: 0, prevItemCount: 0, prevCount: 0, prevPercentage: 0, averageOrderValue: 0, averageItemsPerOrder: 0, prevAvgOrderValue: 0 }); currentDate = this.timeManager.getDayStart(currentDate.plus({ days: 1 })); } // First pass: Count total orders per day using TimeManager's day boundaries for (const event of currentEvents) { const datetime = this.timeManager.toDateTime(event.attributes?.datetime); if (!datetime) continue; // Get the day start for this event's time to ensure proper day assignment const dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.toFormat('yyyy-MM-dd'); if (!dailyStats.has(dateKey)) continue; const dayStats = dailyStats.get(dateKey); dayStats.orders++; dailyStats.set(dateKey, dayStats); } // Second pass: Process filtered orders const filterEvents = (events) => { switch (metric) { case 'pre_orders': return events.filter(event => Boolean(event.event_properties?.HasPreorder)); case 'local_pickup': return events.filter(event => Boolean(event.event_properties?.LocalPickup)); case 'on_hold': return events.filter(event => Boolean(event.event_properties?.IsOnHold)); default: return events; } }; const filteredCurrentEvents = filterEvents(currentEvents); // Process current period filtered events using TimeManager's day boundaries for (const event of filteredCurrentEvents) { const datetime = this.timeManager.toDateTime(event.attributes?.datetime); if (!datetime) continue; // Get the day start for this event's time const dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.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.count++; dayStats.value += totalAmount; dayStats.revenue = dayStats.value; dayStats.itemCount += items.length; dayStats.percentage = (dayStats.count / dayStats.orders) * 100; dayStats.averageOrderValue = dayStats.value / dayStats.count; dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.count; dailyStats.set(dateKey, dayStats); } // Initialize and process previous period stats using TimeManager's day boundaries const prevDailyStats = new Map(); let prevDate = prevPeriodStart; while (prevDate <= prevPeriodEnd) { const dateKey = prevDate.toFormat('yyyy-MM-dd'); prevDailyStats.set(dateKey, { date: prevDate.toISO(), timestamp: dateKey, orders: 0, count: 0, value: 0, percentage: 0 }); prevDate = this.timeManager.getDayStart(prevDate.plus({ days: 1 })); } // First pass for previous period: Count total orders for (const event of prevEvents) { const datetime = this.timeManager.toDateTime(event.attributes?.datetime); if (!datetime) continue; const dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.toFormat('yyyy-MM-dd'); if (!prevDailyStats.has(dateKey)) continue; const dayStats = prevDailyStats.get(dateKey); dayStats.orders++; prevDailyStats.set(dateKey, dayStats); } // Second pass for previous period: Process filtered orders const filteredPrevEvents = filterEvents(prevEvents); for (const event of filteredPrevEvents) { const datetime = this.timeManager.toDateTime(event.attributes?.datetime); if (!datetime) continue; const dayStart = this.timeManager.getDayStart(datetime); const dateKey = dayStart.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); dayStats.count++; dayStats.value += totalAmount; dayStats.percentage = (dayStats.count / dayStats.orders) * 100; prevDailyStats.set(dateKey, dayStats); } // Map previous period data to current period days const prevPeriodDays = Array.from(prevDailyStats.values()).sort((a, b) => a.date.localeCompare(b.date)); const currentPeriodDays = Array.from(dailyStats.values()).sort((a, b) => a.date.localeCompare(b.date)); // Map the data using array indices for (let i = 0; i < currentPeriodDays.length && i < prevPeriodDays.length; i++) { const currentDayStats = currentPeriodDays[i]; const prevDayStats = prevPeriodDays[i]; if (prevDayStats && currentDayStats) { const dayStats = dailyStats.get(currentDayStats.timestamp); if (dayStats) { dayStats.prevValue = prevDayStats.value; dayStats.prevRevenue = prevDayStats.value; dayStats.prevCount = prevDayStats.count; dayStats.prevOrders = prevDayStats.orders; dayStats.prevPercentage = prevDayStats.percentage; dayStats.prevAvgOrderValue = prevDayStats.count > 0 ? prevDayStats.value / prevDayStats.count : 0; dailyStats.set(currentDayStats.timestamp, dayStats); } } } // 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 || day.value || 0), orders: Number(day.orders || 0), itemCount: Number(day.itemCount || 0), count: Number(day.count || 0), value: Number(day.value || 0), percentage: Number(day.percentage || 0), averageOrderValue: Number(day.averageOrderValue || 0), averageItemsPerOrder: Number(day.averageItemsPerOrder || 0), prevRevenue: Number(day.prevRevenue || day.prevValue || 0), prevValue: Number(day.prevValue || 0), prevCount: Number(day.prevCount || 0), prevOrders: Number(day.prevOrders || 0), prevPercentage: Number(day.prevPercentage || 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; } } }