Files
dashboard/dashboard-server/klaviyo-server/services/events.service.js
2024-12-21 11:00:13 -05:00

1715 lines
64 KiB
JavaScript

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