Files
dashboard/dashboard-server/klaviyo-server/services/events.service.js
2024-12-23 22:00:16 -05:00

1878 lines
71 KiB
JavaScript

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.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({
// 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 = 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 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 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);
// Transform and flatten the events into a single array
const allEvents = [];
metrics.forEach((metric, index) => {
const response = results[index];
const events = response?.data || [];
if (Array.isArray(events)) {
const transformedEvents = events.map(event => ({
...event,
metric_id: metric,
datetime: event.attributes?.datetime || event.datetime,
event_properties: {
...event.event_properties,
datetime: event.attributes?.datetime || event.datetime,
},
}));
allEvents.push(...transformedEvents);
}
});
// Sort all events by datetime in descending order
allEvents.sort((a, b) => {
const dateA = new Date(a.datetime);
const dateB = new Date(b.datetime);
return dateB - dateA;
});
const result = { data: allEvents };
// Cache the result
try {
const ttl = this.redisService._getTTL(params.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 the same logic as calculatePeriodStats
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 [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 || []);
// Filter events based on metric type
const filterEvents = (events) => {
switch (metric) {
case 'pre_orders':
return events.filter(event =>
Boolean(event.event_properties?.HasPreorder ||
event.event_properties?.has_preorder ||
event.event_properties?.preorder)
);
case 'local_pickup':
return events.filter(event =>
Boolean(event.event_properties?.LocalPickup ||
event.event_properties?.local_pickup ||
event.event_properties?.pickup)
);
case 'on_hold':
return events.filter(event =>
Boolean(event.event_properties?.IsOnHold ||
event.event_properties?.is_on_hold ||
event.event_properties?.on_hold)
);
default:
return events;
}
};
const filteredCurrentEvents = filterEvents(currentEvents);
const filteredPrevEvents = filterEvents(prevEvents);
// 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,
count: 0,
value: 0,
percentage: 0,
totalOrders: 0,
prevRevenue: 0,
prevOrders: 0,
prevItemCount: 0,
prevAvgOrderValue: 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 });
}
// Get total orders for the period (needed for percentages)
const totalOrders = currentEvents.length;
// Process current period events
for (const event of filteredCurrentEvents) {
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 || [];
const orderId = props.OrderId;
dayStats.revenue += totalAmount;
dayStats.orders++;
dayStats.itemCount += items.length;
dayStats.value += totalAmount;
dayStats.count++;
dayStats.totalOrders = totalOrders;
dayStats.percentage = (dayStats.count / totalOrders) * 100;
dayStats.averageOrderValue = dayStats.revenue / dayStats.orders;
dayStats.averageItemsPerOrder = dayStats.itemCount / 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;
}
}
}
// 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(),
timestamp: dateKey,
revenue: 0,
orders: 0,
itemCount: 0,
value: 0,
count: 0
});
prevDate = prevDate.plus({ days: 1 });
}
// Get total orders for previous period
const prevTotalOrders = prevEvents.length;
// Process previous period events
for (const event of filteredPrevEvents) {
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;
dayStats.value += totalAmount;
dayStats.count++;
dayStats.percentage = (dayStats.count / prevTotalOrders) * 100;
}
// 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.prevRevenue = prevDayStats.revenue;
dayStats.prevOrders = prevDayStats.orders;
dayStats.prevItemCount = prevDayStats.itemCount;
dayStats.prevValue = prevDayStats.value;
dayStats.prevCount = prevDayStats.count;
dayStats.prevPercentage = prevDayStats.percentage;
dayStats.prevAvgOrderValue = prevDayStats.orders > 0 ? prevDayStats.revenue / prevDayStats.orders : 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 || 0),
orders: Number(day.orders || 0),
itemCount: Number(day.itemCount || 0),
averageOrderValue: Number(day.averageOrderValue || 0),
averageItemsPerOrder: Number(day.averageItemsPerOrder || 0),
count: Number(day.count || 0),
value: Number(day.value || 0),
percentage: Number(day.percentage || 0),
totalOrders: Number(day.totalOrders || 0),
prevRevenue: Number(day.prevRevenue || 0),
prevOrders: Number(day.prevOrders || 0),
prevItemCount: Number(day.prevItemCount || 0),
prevValue: Number(day.prevValue || 0),
prevCount: Number(day.prevCount || 0),
prevPercentage: Number(day.prevPercentage || 0),
prevAvgOrderValue: Number(day.prevAvgOrderValue || 0),
orderValueRange: {
largest: Number(day.orderValueRange?.largest || 0),
smallest: Number(day.orderValueRange?.smallest || 0),
largestOrderId: day.orderValueRange?.largestOrderId || null,
smallestOrderId: day.orderValueRange?.smallestOrderId || null,
distribution: {
under25: {
count: Number(day.orderValueRange?.distribution?.under25?.count || 0),
total: Number(day.orderValueRange?.distribution?.under25?.total || 0)
},
under50: {
count: Number(day.orderValueRange?.distribution?.under50?.count || 0),
total: Number(day.orderValueRange?.distribution?.under50?.total || 0)
},
under100: {
count: Number(day.orderValueRange?.distribution?.under100?.count || 0),
total: Number(day.orderValueRange?.distribution?.under100?.total || 0)
},
under200: {
count: Number(day.orderValueRange?.distribution?.under200?.count || 0),
total: Number(day.orderValueRange?.distribution?.under200?.total || 0)
},
over200: {
count: Number(day.orderValueRange?.distribution?.over200?.count || 0),
total: Number(day.orderValueRange?.distribution?.over200?.total || 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;
}
}
}