Files
2025-01-09 11:10:49 -05:00

2203 lines
82 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.getDayStart(this.timeManager.toDateTime(params.startDate));
periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(params.endDate));
const duration = periodEnd.diff(periodStart);
prevPeriodStart = this.timeManager.getDayStart(periodStart.minus(duration));
prevPeriodEnd = this.timeManager.getDayEnd(periodStart.minus({ milliseconds: 1 }));
} else if (params.timeRange) {
const range = this.timeManager.getDateRange(params.timeRange);
periodStart = range.start;
periodEnd = range.end;
const prevRange = this.timeManager.getPreviousPeriod(params.timeRange);
prevPeriodStart = prevRange.start;
prevPeriodEnd = prevRange.end;
}
// Load both current and previous period data
const [orderData, shippedData, refundData, canceledData, prevPeriodData] = await Promise.all([
this.getEvents({
...params,
metricId: METRIC_IDS.PLACED_ORDER
}),
this.getEvents({
...params,
metricId: METRIC_IDS.SHIPPED_ORDER
}),
this.getEvents({
...params,
metricId: METRIC_IDS.PAYMENT_REFUNDED
}),
this.getEvents({
...params,
metricId: METRIC_IDS.CANCELED_ORDER
}),
this.getEvents({
// Only pass through non-date related params for previous period
..._.omit(params, ['timeRange', 'startDate', 'endDate']),
metricId: METRIC_IDS.PLACED_ORDER,
startDate: prevPeriodStart.toISO(),
endDate: prevPeriodEnd.toISO()
})
]);
// Add debug logging
console.log('[EventsService] Previous period request:', {
params: _.omit(params, ['timeRange', 'startDate', 'endDate']),
dates: {
start: prevPeriodStart.toISO(),
end: prevPeriodEnd.toISO()
},
responseLength: prevPeriodData?.data?.length
});
// Transform all data
const transformedOrders = this._transformEvents(orderData.data);
const transformedShipped = this._transformEvents(shippedData.data);
const transformedRefunds = this._transformEvents(refundData.data);
const transformedCanceled = this._transformEvents(canceledData.data);
const transformedPrevPeriod = this._transformEvents(prevPeriodData.data);
// Calculate previous period stats
const prevPeriodRevenue = transformedPrevPeriod.reduce((sum, order) => {
const props = order.event_properties || {};
return sum + Number(props.TotalAmount || 0);
}, 0);
const prevPeriodOrders = transformedPrevPeriod.length;
const prevPeriodAOV = prevPeriodOrders > 0 ? prevPeriodRevenue / prevPeriodOrders : 0;
// Calculate stats with all data available
const stats = {
orderCount: 0,
revenue: 0,
averageOrderValue: 0,
itemCount: 0,
prevPeriodRevenue,
projectedRevenue: 0,
periodProgress: 0,
dailyData: [],
products: {
list: [],
total: 0,
status: {
backordered: 0,
inStock: 0,
outOfStock: 0,
preorder: 0
}
},
shipping: {
shippedCount: 0,
locations: {
byState: [],
byCountry: []
},
methods: {},
methodPercentages: {},
totalRevenue: 0,
averageShipTime: 0,
totalShipTime: 0
},
refunds: {
total: 0,
count: 0,
reasons: {},
items: [],
averageAmount: 0
},
canceledOrders: {
total: 0,
count: 0,
reasons: {},
items: [],
averageAmount: 0
},
orderTypes: {
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
},
brands: {
total: 0,
list: [],
topBrands: [],
totalRevenue: 0,
averageOrderValue: 0
},
categories: {
total: 0,
list: [],
topCategories: [],
totalRevenue: 0,
averageOrderValue: 0
},
hourlyOrders: Array(24).fill(0),
peakOrderHour: null,
bestRevenueDay: null,
orderValueRange: {
largest: 0,
smallest: 0,
largestOrderId: null,
smallestOrderId: null,
distribution: {
under25: { count: 0, total: 0 },
under50: { count: 0, total: 0 },
under100: { count: 0, total: 0 },
under200: { count: 0, total: 0 },
over200: { count: 0, total: 0 }
}
}
};
// Calculate period progress
if (periodStart && periodEnd) {
const totalDuration = periodEnd.diff(periodStart);
const elapsedDuration = this.timeManager.getNow().diff(periodStart);
stats.periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100));
}
// Process orders
const brandMap = new Map();
const categoryMap = new Map();
const dailyOrderCounts = {};
const dailyStats = new Map();
// Track best day stats
let bestDay = {
date: null,
displayDate: null,
amount: 0,
orderCount: 0
};
// Initialize daily stats for the entire date range
if (periodStart && periodEnd) {
let currentDate = periodStart;
while (currentDate <= periodEnd) {
const dateKey = currentDate.toFormat('yyyy-MM-dd');
dailyStats.set(dateKey, {
date: currentDate.toISO(), // ISO format for chart library
timestamp: dateKey,
revenue: 0,
orders: 0,
itemCount: 0,
averageOrderValue: 0,
averageItemsPerOrder: 0,
hourlyOrders: Array(24).fill(0),
refunds: { total: 0, count: 0, reasons: {}, items: [] },
canceledOrders: { total: 0, count: 0, reasons: {}, items: [] },
orderTypes: {
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
},
orderValueRange: {
largest: 0,
smallest: 0,
largestOrderId: null,
smallestOrderId: null,
distribution: {
under25: { count: 0, total: 0 },
under50: { count: 0, total: 0 },
under100: { count: 0, total: 0 },
under200: { count: 0, total: 0 },
over200: { count: 0, total: 0 }
}
}
});
currentDate = this.timeManager.getDayStart(currentDate.plus({ days: 1 }));
}
}
// Track peak hour stats
let maxHourCount = 0;
let peakHour = 0;
for (const order of transformedOrders) {
const props = order.event_properties || {};
const items = props.Items || [];
const totalAmount = Number(props.TotalAmount || 0);
const datetime = this.timeManager.toDateTime(order.attributes?.datetime);
const orderId = props.OrderId;
// Update order counts and revenue
stats.orderCount++;
stats.revenue += totalAmount;
stats.itemCount += items.length;
// Calculate running AOV
stats.averageOrderValue = stats.revenue / stats.orderCount;
// Track daily stats
if (datetime) {
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
const hourOfDay = datetime.hour;
// Initialize stats for this date if it doesn't exist
if (!dailyStats.has(dateKey)) {
dailyStats.set(dateKey, {
date: datetime.toISO(), // ISO format for chart library
timestamp: dateKey,
revenue: 0,
orders: 0,
itemCount: 0,
averageOrderValue: 0,
averageItemsPerOrder: 0,
hourlyOrders: Array(24).fill(0),
refunds: { total: 0, count: 0, reasons: {}, items: [] },
canceledOrders: { total: 0, count: 0, reasons: {}, items: [] },
orderTypes: {
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
},
orderValueRange: {
largest: 0,
smallest: 0,
largestOrderId: null,
smallestOrderId: null,
distribution: {
under25: { count: 0, total: 0 },
under50: { count: 0, total: 0 },
under100: { count: 0, total: 0 },
under200: { count: 0, total: 0 },
over200: { count: 0, total: 0 }
}
}
});
}
const dayStats = dailyStats.get(dateKey);
// Update daily stats
dayStats.revenue += totalAmount;
dayStats.orders++;
dayStats.itemCount += items.length;
dayStats.hourlyOrders[hourOfDay]++;
dayStats.averageOrderValue = dayStats.revenue / dayStats.orders;
dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.orders;
// Track best day
if (dayStats.revenue > bestDay.amount) {
bestDay = {
date: dateKey,
displayDate: dayStart.toFormat('LLL d, yyyy'),
amount: dayStats.revenue,
orderCount: dayStats.orders
};
}
// Track daily order value range
if (totalAmount > dayStats.orderValueRange.largest) {
dayStats.orderValueRange.largest = totalAmount;
dayStats.orderValueRange.largestOrderId = orderId;
}
if (totalAmount > 0) {
if (dayStats.orderValueRange.smallest === 0 || totalAmount < dayStats.orderValueRange.smallest) {
dayStats.orderValueRange.smallest = totalAmount;
dayStats.orderValueRange.smallestOrderId = orderId;
}
// Track order value distribution
if (totalAmount < 25) {
dayStats.orderValueRange.distribution.under25.count++;
dayStats.orderValueRange.distribution.under25.total += totalAmount;
} else if (totalAmount < 50) {
dayStats.orderValueRange.distribution.under50.count++;
dayStats.orderValueRange.distribution.under50.total += totalAmount;
} else if (totalAmount < 100) {
dayStats.orderValueRange.distribution.under100.count++;
dayStats.orderValueRange.distribution.under100.total += totalAmount;
} else if (totalAmount < 200) {
dayStats.orderValueRange.distribution.under200.count++;
dayStats.orderValueRange.distribution.under200.total += totalAmount;
} else {
dayStats.orderValueRange.distribution.over200.count++;
dayStats.orderValueRange.distribution.over200.total += totalAmount;
}
}
// Track order types
if (props.HasPreorder) {
dayStats.orderTypes.preOrders.count++;
dayStats.orderTypes.preOrders.value += totalAmount;
dayStats.orderTypes.preOrders.percentage = (dayStats.orderTypes.preOrders.count / dayStats.orders) * 100;
dayStats.orderTypes.preOrders.averageValue = dayStats.orderTypes.preOrders.value / dayStats.orderTypes.preOrders.count;
}
if (props.LocalPickup) {
dayStats.orderTypes.localPickup.count++;
dayStats.orderTypes.localPickup.value += totalAmount;
dayStats.orderTypes.localPickup.percentage = (dayStats.orderTypes.localPickup.count / dayStats.orders) * 100;
dayStats.orderTypes.localPickup.averageValue = dayStats.orderTypes.localPickup.value / dayStats.orderTypes.localPickup.count;
}
if (props.IsOnHold) {
dayStats.orderTypes.heldItems.count++;
dayStats.orderTypes.heldItems.value += totalAmount;
dayStats.orderTypes.heldItems.percentage = (dayStats.orderTypes.heldItems.count / dayStats.orders) * 100;
dayStats.orderTypes.heldItems.averageValue = dayStats.orderTypes.heldItems.value / dayStats.orderTypes.heldItems.count;
}
if (props.HasDigiItem) {
dayStats.orderTypes.digital.count++;
dayStats.orderTypes.digital.value += totalAmount;
dayStats.orderTypes.digital.percentage = (dayStats.orderTypes.digital.count / dayStats.orders) * 100;
dayStats.orderTypes.digital.averageValue = dayStats.orderTypes.digital.value / dayStats.orderTypes.digital.count;
}
if (props.HasDigitalGC) {
dayStats.orderTypes.giftCard.count++;
dayStats.orderTypes.giftCard.value += totalAmount;
dayStats.orderTypes.giftCard.percentage = (dayStats.orderTypes.giftCard.count / dayStats.orders) * 100;
dayStats.orderTypes.giftCard.averageValue = dayStats.orderTypes.giftCard.value / dayStats.orderTypes.giftCard.count;
}
// Update hourly stats for peak hour calculation
if (dayStats.hourlyOrders[hourOfDay] > maxHourCount) {
maxHourCount = dayStats.hourlyOrders[hourOfDay];
peakHour = hourOfDay;
}
dailyStats.set(dateKey, dayStats);
}
// Track order value range
if (totalAmount > stats.orderValueRange.largest) {
stats.orderValueRange.largest = totalAmount;
stats.orderValueRange.largestOrderId = orderId;
}
if (totalAmount > 0) {
if (stats.orderValueRange.smallest === 0 || totalAmount < stats.orderValueRange.smallest) {
stats.orderValueRange.smallest = totalAmount;
stats.orderValueRange.smallestOrderId = orderId;
}
// Track order value distribution
if (totalAmount < 25) {
stats.orderValueRange.distribution.under25.count++;
stats.orderValueRange.distribution.under25.total += totalAmount;
} else if (totalAmount < 50) {
stats.orderValueRange.distribution.under50.count++;
stats.orderValueRange.distribution.under50.total += totalAmount;
} else if (totalAmount < 100) {
stats.orderValueRange.distribution.under100.count++;
stats.orderValueRange.distribution.under100.total += totalAmount;
} else if (totalAmount < 200) {
stats.orderValueRange.distribution.under200.count++;
stats.orderValueRange.distribution.under200.total += totalAmount;
} else {
stats.orderValueRange.distribution.over200.count++;
stats.orderValueRange.distribution.over200.total += totalAmount;
}
}
// Track order types with values
if (props.HasPreorder) {
stats.orderTypes.preOrders.count++;
stats.orderTypes.preOrders.value += totalAmount;
stats.orderTypes.preOrders.items.push({ orderId, amount: totalAmount, items });
stats.orderTypes.preOrders.percentage = (stats.orderTypes.preOrders.count / stats.orderCount) * 100;
stats.orderTypes.preOrders.averageValue = stats.orderTypes.preOrders.value / stats.orderTypes.preOrders.count;
}
if (props.LocalPickup) {
stats.orderTypes.localPickup.count++;
stats.orderTypes.localPickup.value += totalAmount;
stats.orderTypes.localPickup.items.push({ orderId, amount: totalAmount, items });
stats.orderTypes.localPickup.percentage = (stats.orderTypes.localPickup.count / stats.orderCount) * 100;
stats.orderTypes.localPickup.averageValue = stats.orderTypes.localPickup.value / stats.orderTypes.localPickup.count;
}
if (props.IsOnHold) {
stats.orderTypes.heldItems.count++;
stats.orderTypes.heldItems.value += totalAmount;
stats.orderTypes.heldItems.items.push({ orderId, amount: totalAmount, items });
stats.orderTypes.heldItems.percentage = (stats.orderTypes.heldItems.count / stats.orderCount) * 100;
stats.orderTypes.heldItems.averageValue = stats.orderTypes.heldItems.value / stats.orderTypes.heldItems.count;
}
if (props.HasDigiItem) {
stats.orderTypes.digital.count++;
stats.orderTypes.digital.value += totalAmount;
stats.orderTypes.digital.items.push({ orderId, amount: totalAmount, items });
stats.orderTypes.digital.percentage = (stats.orderTypes.digital.count / stats.orderCount) * 100;
stats.orderTypes.digital.averageValue = stats.orderTypes.digital.value / stats.orderTypes.digital.count;
}
if (props.HasDigitalGC) {
stats.orderTypes.giftCard.count++;
stats.orderTypes.giftCard.value += totalAmount;
stats.orderTypes.giftCard.items.push({ orderId, amount: totalAmount, items });
stats.orderTypes.giftCard.percentage = (stats.orderTypes.giftCard.count / stats.orderCount) * 100;
stats.orderTypes.giftCard.averageValue = stats.orderTypes.giftCard.value / stats.orderTypes.giftCard.count;
}
// Track hourly and daily stats
if (datetime) {
const hour = datetime.hour;
stats.hourlyOrders[hour]++;
}
// Process products and related data
for (const item of items) {
const productId = item.ProductID;
const quantity = Number(item.Quantity || item.QuantityOrdered || 1);
const revenue = Number(item.RowTotal || (item.ItemPrice * quantity) || 0);
// Track item status
switch(item.ItemStatus?.toLowerCase()) {
case 'backordered':
stats.products.status.backordered++;
break;
case 'out of stock':
stats.products.status.outOfStock++;
break;
case 'preorder':
stats.products.status.preorder++;
break;
default:
stats.products.status.inStock++;
}
// Update product stats
const existingProduct = stats.products.list.find(p => p.id === productId);
if (existingProduct) {
existingProduct.totalQuantity += quantity;
existingProduct.totalRevenue += revenue;
existingProduct.orderCount++;
existingProduct.orders.add(orderId);
} else {
stats.products.list.push({
id: productId,
sku: item.SKU,
name: item.ProductName,
brand: item.Brand,
price: Number(item.ItemPrice || 0),
ImgThumb: item.ImgThumb,
totalQuantity: quantity,
totalRevenue: revenue,
orderCount: 1,
orders: new Set([orderId]),
categories: item.Categories || [],
status: item.ItemStatus || 'In Stock'
});
}
// Update brand stats
if (item.Brand) {
const brand = brandMap.get(item.Brand) || {
name: item.Brand,
quantity: 0,
revenue: 0,
products: new Set()
};
brand.quantity += quantity;
brand.revenue += revenue;
brand.products.add(productId);
brandMap.set(item.Brand, brand);
}
// Update category stats
if (item.Categories) {
for (const category of item.Categories) {
const categoryStats = categoryMap.get(category) || {
name: category,
quantity: 0,
revenue: 0,
products: new Set()
};
categoryStats.quantity += quantity;
categoryStats.revenue += revenue;
categoryStats.products.add(productId);
categoryMap.set(category, categoryStats);
}
}
}
}
// After processing all orders
if (stats.orderCount > 0) {
stats.averageOrderValue = stats.revenue / stats.orderCount;
stats.averageItemsPerOrder = stats.itemCount / stats.orderCount;
}
// Calculate projected revenue for incomplete periods
if (periodStart && periodEnd) {
const totalDuration = periodEnd.diff(periodStart);
const elapsedDuration = this.timeManager.getNow().diff(periodStart);
const periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100));
if (periodProgress > 0 && periodProgress < 100) {
stats.projectedRevenue = (stats.revenue / (periodProgress / 100));
} else {
stats.projectedRevenue = stats.revenue;
}
stats.periodProgress = periodProgress;
}
// Calculate trend data only for revenue
if (prevPeriodRevenue > 0) {
stats.trend = {
revenue: ((stats.revenue - prevPeriodRevenue) / prevPeriodRevenue) * 100
};
}
// Process shipped orders with better formatting
const shippingMethodMap = new Map();
const locationMap = new Map();
const stateMap = new Map();
const countryMap = new Map();
for (const shipped of transformedShipped) {
const props = shipped.event_properties || {};
stats.shipping.shippedCount++;
// Track shipping methods
const method = props.ShipMethod || props.ShippingMethod || 'Unknown';
const currentMethodCount = shippingMethodMap.get(method) || 0;
shippingMethodMap.set(method, currentMethodCount + 1);
// Track locations by state and country
const state = props.ShippingState?.trim() || 'Unknown State';
const country = props.ShippingCountry?.trim() || 'Unknown Country';
// Track unique locations
const locationKey = `${state}-${country}`;
locationMap.set(locationKey, true);
// Track by state
const stateStats = stateMap.get(state) || { count: 0, country };
stateStats.count++;
stateMap.set(state, stateStats);
// Track by country
const countryStats = countryMap.get(country) || { count: 0, states: new Set() };
countryStats.count++;
countryStats.states.add(state);
countryMap.set(country, countryStats);
}
// Format shipping methods
stats.shipping.methods = Object.fromEntries(shippingMethodMap);
stats.shipping.methodPercentages = {};
stats.shipping.methodStats = [];
shippingMethodMap.forEach((count, method) => {
const percentage = (count / stats.shipping.shippedCount) * 100;
stats.shipping.methodPercentages[method] = percentage;
stats.shipping.methodStats.push({
name: method,
value: count,
percentage
});
});
stats.shipping.methodStats.sort((a, b) => b.value - a.value);
// Format locations by state and country
stats.shipping.locations = {
total: locationMap.size,
byState: Array.from(stateMap.entries())
.map(([state, data]) => ({
state,
country: data.country,
count: data.count,
percentage: (data.count / stats.shipping.shippedCount) * 100
}))
.sort((a, b) => b.count - a.count),
byCountry: Array.from(countryMap.entries())
.map(([country, data]) => ({
country,
count: data.count,
states: Array.from(data.states),
percentage: (data.count / stats.shipping.shippedCount) * 100
}))
.sort((a, b) => b.count - a.count)
};
// Process refunds with more detail
for (const refund of transformedRefunds) {
const props = refund.event_properties || {};
const amount = Number(props.PaymentAmount || 0);
const reason = props.CancelReason || props.OrderMessage || 'No reason provided';
const datetime = this.timeManager.toDateTime(refund.attributes?.datetime);
const orderId = props.OrderId || props.FromOrder;
stats.refunds.total += amount;
stats.refunds.count++;
stats.refunds.reasons[reason] = (stats.refunds.reasons[reason] || 0) + 1;
stats.refunds.items.push({
orderId,
amount,
reason,
datetime: datetime?.toISO()
});
stats.refunds.averageAmount = stats.refunds.total / stats.refunds.count;
// Track daily refunds
if (datetime) {
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
// Initialize stats for this date if it doesn't exist
if (!dailyStats.has(dateKey)) {
dailyStats.set(dateKey, {
date: datetime.toISO(), // ISO format for chart library
timestamp: dateKey,
revenue: 0,
orders: 0,
itemCount: 0,
averageOrderValue: 0,
averageItemsPerOrder: 0,
hourlyOrders: Array(24).fill(0),
refunds: { total: 0, count: 0, reasons: {}, items: [] },
canceledOrders: { total: 0, count: 0, reasons: {}, items: [] },
orderTypes: {
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
},
orderValueRange: {
largest: 0,
smallest: 0,
largestOrderId: null,
smallestOrderId: null,
distribution: {
under25: { count: 0, total: 0 },
under50: { count: 0, total: 0 },
under100: { count: 0, total: 0 },
under200: { count: 0, total: 0 },
over200: { count: 0, total: 0 }
}
}
});
}
const dayStats = dailyStats.get(dateKey);
dayStats.refunds.total += amount;
dayStats.refunds.count++;
dayStats.refunds.reasons[reason] = (dayStats.refunds.reasons[reason] || 0) + 1;
dailyStats.set(dateKey, dayStats);
}
}
// Process canceled orders with more detail
for (const canceled of transformedCanceled) {
const props = canceled.event_properties || {};
const amount = Number(props.TotalAmount || 0);
const reason = props.CancelReason || props.OrderMessage || 'No reason provided';
const datetime = this.timeManager.toDateTime(canceled.attributes?.datetime);
const orderId = props.OrderId || props.FromOrder;
stats.canceledOrders = stats.canceledOrders || { total: 0, count: 0, reasons: {}, items: [], averageAmount: 0 };
stats.canceledOrders.total += amount;
stats.canceledOrders.count++;
stats.canceledOrders.reasons[reason] = (stats.canceledOrders.reasons[reason] || 0) + 1;
stats.canceledOrders.items.push({
orderId,
amount,
reason,
datetime: datetime?.toISO()
});
stats.canceledOrders.averageAmount = stats.canceledOrders.total / stats.canceledOrders.count;
// Track daily cancellations
if (datetime) {
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
// Initialize stats for this date if it doesn't exist
if (!dailyStats.has(dateKey)) {
dailyStats.set(dateKey, {
date: datetime.toISO(), // ISO format for chart library
timestamp: dateKey,
revenue: 0,
orders: 0,
itemCount: 0,
averageOrderValue: 0,
averageItemsPerOrder: 0,
hourlyOrders: Array(24).fill(0),
refunds: { total: 0, count: 0, reasons: {}, items: [] },
canceledOrders: { total: 0, count: 0, reasons: {}, items: [] },
orderTypes: {
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
},
orderValueRange: {
largest: 0,
smallest: 0,
largestOrderId: null,
smallestOrderId: null,
distribution: {
under25: { count: 0, total: 0 },
under50: { count: 0, total: 0 },
under100: { count: 0, total: 0 },
under200: { count: 0, total: 0 },
over200: { count: 0, total: 0 }
}
}
});
}
const dayStats = dailyStats.get(dateKey);
dayStats.canceledOrders.total += amount;
dayStats.canceledOrders.count++;
dayStats.canceledOrders.reasons[reason] = (dayStats.canceledOrders.reasons[reason] || 0) + 1;
dailyStats.set(dateKey, dayStats);
}
}
// Set peak hour stats
if (maxHourCount > 0) {
stats.peakOrderHour = {
hour: peakHour,
count: maxHourCount,
displayHour: this._formatHour(peakHour),
hourData: stats.hourlyOrders.map((count, hour) => ({
hour,
displayHour: this._formatHour(hour),
count,
percentage: (count / stats.orderCount) * 100
}))
};
}
// Set best day stats
if (bestDay.date) {
stats.bestRevenueDay = {
...bestDay,
dialogType: 'revenue', // Add dialog type for frontend
dialogTitle: 'Revenue Details' // Add dialog title for frontend
};
}
// Process products with status tracking
stats.products.list = stats.products.list.map(product => ({
...product,
averageOrderValue: product.totalRevenue / product.orderCount,
orders: Array.from(product.orders)
}));
stats.products.list.sort((a, b) => b.totalRevenue - a.totalRevenue);
stats.products.total = stats.products.list.length;
// Format brands with more detail and period-specific data
stats.brands.list = Array.from(brandMap.values())
.map(brand => {
const brandStats = {
name: brand.name,
count: brand.quantity,
revenue: brand.revenue,
productCount: brand.products.size,
percentage: (brand.revenue / stats.revenue) * 100, // Calculate percentage based on period revenue
averageOrderValue: brand.revenue / brand.quantity,
products: Array.from(brand.products)
};
// Add display formatting for charts
brandStats.tooltipLabel = `${brand.name}\nRevenue: $${brand.revenue.toFixed(2)}\nItems: ${brand.quantity}`;
return brandStats;
})
.sort((a, b) => b.revenue - a.revenue);
stats.brands.topBrands = stats.brands.list.slice(0, 10);
stats.brands.totalRevenue = stats.revenue; // Use period revenue
stats.brands.averageOrderValue = stats.revenue / stats.itemCount;
stats.brands.total = stats.brands.list.length;
// Format categories with more detail and period-specific data
stats.categories.list = Array.from(categoryMap.values())
.map(category => {
const categoryStats = {
name: category.name,
count: category.quantity,
revenue: category.revenue,
productCount: category.products.size,
percentage: (category.revenue / stats.revenue) * 100, // Calculate percentage based on period revenue
averageOrderValue: category.revenue / category.quantity,
products: Array.from(category.products)
};
// Add display formatting for charts
categoryStats.tooltipLabel = `${category.name}\nRevenue: $${category.revenue.toFixed(2)}\nItems: ${category.quantity}`;
return categoryStats;
})
.sort((a, b) => b.revenue - a.revenue);
stats.categories.topCategories = stats.categories.list.slice(0, 10);
stats.categories.totalRevenue = stats.revenue; // Use period revenue
stats.categories.averageOrderValue = stats.revenue / stats.itemCount;
stats.categories.total = stats.categories.list.length;
// Remove pie chart labels and create a key
stats.brands.key = stats.brands.list.map(brand => ({
name: brand.name,
color: this._getColorForBrand(brand.name)
}));
stats.categories.key = stats.categories.list.map(category => ({
name: category.name,
color: this._getColorForCategory(category.name)
}));
// Set peak hour stats with proper formatting
if (maxHourCount > 0) {
stats.peakOrderHour = {
hour: peakHour,
count: maxHourCount,
displayHour: this._formatHour(peakHour),
hourData: stats.hourlyOrders.map((count, hour) => ({
hour,
displayHour: this._formatHour(hour),
count,
percentage: (count / stats.orderCount) * 100
}))
};
}
// Set best day stats with link to revenue dialog
if (bestDay.date) {
stats.bestRevenueDay = {
...bestDay,
dialogType: 'revenue', // Add dialog type for frontend
dialogTitle: 'Revenue Details' // Add dialog title for frontend
};
}
// Add daily stats for order types
const orderTypeStats = {
preOrders: { dailyData: [] },
localPickup: { dailyData: [] },
heldItems: { dailyData: [] }
};
// Process daily stats for order types
stats.dailyData.forEach(day => {
// Pre-orders daily data
orderTypeStats.preOrders.dailyData.push({
date: day.date,
count: day.orderTypes.preOrders.count,
value: day.orderTypes.preOrders.value,
percentage: day.orderTypes.preOrders.percentage,
totalOrders: day.orders
});
// Local pickup daily data
orderTypeStats.localPickup.dailyData.push({
date: day.date,
count: day.orderTypes.localPickup.count,
value: day.orderTypes.localPickup.value,
percentage: day.orderTypes.localPickup.percentage,
totalOrders: day.orders
});
// Held items daily data
orderTypeStats.heldItems.dailyData.push({
date: day.date,
count: day.orderTypes.heldItems.count,
value: day.orderTypes.heldItems.value,
percentage: day.orderTypes.heldItems.percentage,
totalOrders: day.orders
});
});
// Add order type stats to main stats object
stats.orderTypes.preOrders.dailyData = orderTypeStats.preOrders.dailyData;
stats.orderTypes.localPickup.dailyData = orderTypeStats.localPickup.dailyData;
stats.orderTypes.heldItems.dailyData = orderTypeStats.heldItems.dailyData;
// Convert daily stats to array and sort
stats.dailyData = Array.from(dailyStats.values())
.sort((a, b) => a.date.localeCompare(b.date))
.map(day => ({
date: day.date,
timestamp: day.timestamp,
revenue: Number(day.revenue || 0),
orders: Number(day.orders || 0),
itemCount: Number(day.itemCount || 0),
averageOrderValue: Number(day.averageOrderValue || 0),
averageItemsPerOrder: Number(day.averageItemsPerOrder || 0),
hourlyOrders: day.hourlyOrders || Array(24).fill(0),
refunds: {
total: Number(day.refunds?.total || 0),
count: Number(day.refunds?.count || 0),
reasons: day.refunds?.reasons || {}
},
canceledOrders: {
total: Number(day.canceledOrders?.total || 0),
count: Number(day.canceledOrders?.count || 0),
reasons: day.canceledOrders?.reasons || {}
},
orderTypes: {
preOrders: {
count: Number(day.orderTypes?.preOrders?.count || 0),
value: Number(day.orderTypes?.preOrders?.value || 0),
percentage: Number(day.orderTypes?.preOrders?.percentage || 0),
averageValue: Number(day.orderTypes?.preOrders?.averageValue || 0)
},
localPickup: {
count: Number(day.orderTypes?.localPickup?.count || 0),
value: Number(day.orderTypes?.localPickup?.value || 0),
percentage: Number(day.orderTypes?.localPickup?.percentage || 0),
averageValue: Number(day.orderTypes?.localPickup?.averageValue || 0)
},
heldItems: {
count: Number(day.orderTypes?.heldItems?.count || 0),
value: Number(day.orderTypes?.heldItems?.value || 0),
percentage: Number(day.orderTypes?.heldItems?.percentage || 0),
averageValue: Number(day.orderTypes?.heldItems?.averageValue || 0)
},
digital: {
count: Number(day.orderTypes?.digital?.count || 0),
value: Number(day.orderTypes?.digital?.value || 0),
percentage: Number(day.orderTypes?.digital?.percentage || 0),
averageValue: Number(day.orderTypes?.digital?.averageValue || 0)
},
giftCard: {
count: Number(day.orderTypes?.giftCard?.count || 0),
value: Number(day.orderTypes?.giftCard?.value || 0),
percentage: Number(day.orderTypes?.giftCard?.percentage || 0),
averageValue: Number(day.orderTypes?.giftCard?.averageValue || 0)
}
}
}));
// Set totals
stats.brands.total = stats.brands.list.length;
stats.categories.total = stats.categories.list.length;
return stats;
} catch (error) {
console.error('[EventsService] Error calculating period stats:', error);
throw error;
}
}
_getEmptyStats() {
return {
orderCount: 0,
revenue: 0,
averageOrderValue: 0,
itemCount: 0,
prevPeriodRevenue: 0,
projectedRevenue: 0,
periodProgress: 0,
dailyData: [],
products: {
list: [],
total: 0,
status: {
backordered: 0,
inStock: 0,
outOfStock: 0,
preorder: 0
}
},
shipping: {
shippedCount: 0,
locations: {
total: 0,
byState: [],
byCountry: []
},
methods: {},
methodStats: [],
methodPercentages: {}
},
refunds: {
total: 0,
count: 0,
reasons: {},
items: [],
averageAmount: 0
},
canceledOrders: {
total: 0,
count: 0,
reasons: {},
items: [],
averageAmount: 0
},
orderTypes: {
preOrders: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
localPickup: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
heldItems: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
digital: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 },
giftCard: { count: 0, percentage: 0, value: 0, items: [], averageValue: 0 }
},
brands: {
total: 0,
list: [],
topBrands: [],
totalRevenue: 0,
averageOrderValue: 0
},
categories: {
total: 0,
list: [],
topCategories: [],
totalRevenue: 0,
averageOrderValue: 0
},
hourlyOrders: Array(24).fill(0),
peakOrderHour: null,
bestRevenueDay: null,
orderValueRange: {
largest: 0,
smallest: 0,
largestOrderId: null,
smallestOrderId: null,
distribution: {
under25: { count: 0, total: 0 },
under50: { count: 0, total: 0 },
under100: { count: 0, total: 0 },
under200: { count: 0, total: 0 },
over200: { count: 0, total: 0 }
}
}
};
}
async getMultiMetricEvents(params = {}) {
try {
const { timeRange, startDate, endDate, metricIds } = params;
const metrics = metricIds || Object.values(METRIC_IDS);
// Get period dates using TimeManager to respect 1 AM day start
let periodStart, periodEnd;
if (startDate && endDate) {
periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(startDate));
periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(endDate));
} else if (timeRange) {
const range = this.timeManager.getDateRange(timeRange);
periodStart = range.start;
periodEnd = range.end;
}
// Try to get from cache first
const cacheKey = this.redisService._getCacheKey('events', params);
let cachedData = null;
try {
cachedData = await this.redisService.get(`${cacheKey}:feed`);
if (cachedData) {
return cachedData;
}
} catch (cacheError) {
console.warn('[EventsService] Cache error:', cacheError);
}
// Fetch events for all specified metrics
const eventPromises = metrics.map(metricId =>
this.getEvents({
startDate: periodStart.toISO(),
endDate: periodEnd.toISO(),
timeRange,
metricId,
sort: '-datetime'
})
);
const results = await Promise.all(eventPromises);
// Transform and flatten the events into a single array
const allEvents = [];
results.forEach((result) => {
if (result && Array.isArray(result.data)) {
allEvents.push(...result.data);
}
});
// Sort all events by datetime in descending order
allEvents.sort((a, b) => {
const dateA = new Date(a.attributes?.datetime || 0);
const dateB = new Date(b.attributes?.datetime || 0);
return dateB - dateA;
});
const result = {
data: allEvents,
meta: {
total_count: allEvents.length
}
};
// Cache the result
try {
const ttl = this.redisService._getTTL(timeRange);
await this.redisService.set(`${cacheKey}:feed`, result, ttl);
} catch (cacheError) {
console.warn('[EventsService] Cache set error:', cacheError);
}
return result;
} catch (error) {
console.error('[EventsService] Error in batch metrics:', error);
throw error;
}
}
_transformEvents(events) {
if (!Array.isArray(events)) {
console.warn('[EventsService] Events is not an array:', events);
return [];
}
return events.map(event => {
try {
// Extract metric ID from all possible locations
const metricId = event.relationships?.metric?.data?.id ||
event.attributes?.metric?.id ||
event.attributes?.metric_id;
// Extract properties from all possible locations
const rawProps = event.attributes?.event_properties || {};
// Only log for shipped orders and only show relevant fields
if (event.relationships?.metric?.data?.id === METRIC_IDS.SHIPPED_ORDER) {
console.log('[EventsService] Shipped Order:', {
orderId: rawProps.OrderId,
shippedBy: rawProps.ShippedBy,
datetime: event.attributes?.datetime
});
}
// Normalize shipping data
const shippingData = {
ShippingName: rawProps.ShippingName,
ShippingStreet1: rawProps.ShippingStreet1,
ShippingStreet2: rawProps.ShippingStreet2,
ShippingCity: rawProps.ShippingCity,
ShippingState: rawProps.ShippingState,
ShippingZip: rawProps.ShippingZip,
ShippingCountry: rawProps.ShippingCountry,
ShipMethod: rawProps.ShipMethod,
TrackingNumber: rawProps.TrackingNumber,
ShippedBy: rawProps.ShippedBy
};
// Normalize payment data
const paymentData = {
method: rawProps.PaymentMethod,
name: rawProps.PaymentName,
amount: Number(rawProps.PaymentAmount || 0)
};
// Normalize order flags
const orderFlags = {
type: rawProps.OrderType || 'standard',
hasPreorder: Boolean(rawProps.HasPreorder),
localPickup: Boolean(rawProps.LocalPickup),
isOnHold: Boolean(rawProps.IsOnHold),
hasDigiItem: Boolean(rawProps.HasDigiItem),
hasNotions: Boolean(rawProps.HasNotions),
hasDigitalGC: Boolean(rawProps.HasDigitalGC),
stillOwes: Boolean(rawProps.StillOwes)
};
// Normalize refund/cancel data
const refundData = {
reason: rawProps.CancelReason,
message: rawProps.CancelMessage,
orderMessage: rawProps.OrderMessage
};
// Transform items
const items = this._transformItems(rawProps.Items || []);
// Calculate totals
const totalAmount = Number(rawProps.TotalAmount || rawProps.PaymentAmount || 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: {
...rawProps, // Include all original properties
Items: items, // Override with transformed items
TotalAmount: totalAmount,
ItemCount: itemCount
}
};
return transformed;
} catch (error) {
console.error('[EventsService] Error transforming event:', error, event);
// Return a minimal valid event structure
return {
id: event.id || 'unknown',
type: event.type || 'unknown',
metric_id: event.relationships?.metric?.data?.id || 'unknown',
attributes: event.attributes || {},
event_properties: {}
};
}
}).filter(Boolean); // Remove any null/undefined events
}
_transformItems(items) {
if (!Array.isArray(items)) {
console.warn('[EventsService] Items is not an array:', items);
return [];
}
return items.map(item => {
try {
const quantity = Number(item.Quantity || item.QuantityOrdered || item.quantity || item.quantity_ordered || 1);
const price = Number(item.ItemPrice || item.item_price || item.price || 0);
const rowTotal = Number(item.RowTotal || item.row_total || (price * quantity) || 0);
const transformed = {
// Basic item information
ProductID: item.ProductID || item.product_id || item.id,
ProductName: item.ProductName || item.product_name || item.name,
SKU: item.SKU || item.sku,
Brand: item.Brand || item.brand,
Categories: Array.isArray(item.Categories) ? item.Categories :
Array.isArray(item.categories) ? item.categories : [],
// Pricing
ItemPrice: price,
RowTotal: rowTotal,
// Quantities
Quantity: quantity,
QuantityOrdered: quantity,
QuantitySent: Number(item.QuantitySent || item.quantity_sent || 0),
QuantityBackordered: Number(item.QuantityBackordered || item.quantity_backordered || 0),
// Status and images
ItemStatus: item.ItemStatus || item.item_status || item.status || 'In Stock',
ImgThumb: item.ImgThumb || item.img_thumb || item.thumbnail,
// Additional properties
IsPreorder: Boolean(item.IsPreorder || item.is_preorder || item.preorder),
IsDigital: Boolean(item.IsDigital || item.is_digital || item.digital),
IsGiftCard: Boolean(item.IsGiftCard || item.is_gift_card || item.gift_card),
// Original properties (for backward compatibility)
...item
};
return transformed;
} catch (error) {
console.error('[EventsService] Error transforming item:', error, item);
// Return a minimal valid item structure
return {
ProductID: item.ProductID || item.product_id || 'unknown',
ProductName: item.ProductName || item.product_name || 'Unknown Product',
Quantity: 1,
ItemPrice: 0,
RowTotal: 0
};
}
});
}
_formatHour(hour) {
if (hour === 0) return "12:00 AM";
if (hour === 12) return "12:00 PM";
if (hour > 12) return `${hour - 12}:00 PM`;
return `${hour}:00 AM`;
}
async calculateDetailedStats(params = {}) {
try {
const { metric, daily = false } = params;
console.log('[EventsService] Request params:', params);
// Get period dates using TimeManager to respect 1 AM day start
let periodStart, periodEnd, prevPeriodStart, prevPeriodEnd;
if (params.startDate && params.endDate) {
periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(params.startDate));
periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(params.endDate));
const duration = periodEnd.diff(periodStart);
prevPeriodStart = this.timeManager.getDayStart(periodStart.minus(duration));
prevPeriodEnd = this.timeManager.getDayEnd(periodStart.minus({ milliseconds: 1 }));
} else if (params.timeRange) {
const range = this.timeManager.getDateRange(params.timeRange);
periodStart = range.start;
periodEnd = range.end;
const prevRange = this.timeManager.getPreviousPeriod(params.timeRange);
prevPeriodStart = prevRange.start;
prevPeriodEnd = prevRange.end;
}
// For order range, we need to process all orders with their value distribution
if (metric === 'order_range') {
const [currentEvents] = await Promise.all([
this.getEvents({
...params,
startDate: periodStart.toISO(),
endDate: periodEnd.toISO(),
metricId: METRIC_IDS.PLACED_ORDER
})
]);
// Transform events
const transformedEvents = this._transformEvents(currentEvents.data || []);
console.log(`[EventsService] Processing ${transformedEvents.length} orders for order range`);
// Initialize daily stats map with all dates in range
const dailyStats = new Map();
let currentDate = periodStart;
while (currentDate <= periodEnd) {
const dateKey = currentDate.toFormat('yyyy-MM-dd');
dailyStats.set(dateKey, {
date: currentDate.toISO(),
timestamp: dateKey,
orders: 0,
averageOrderValue: 0,
orderValueRange: {
largest: 0,
smallest: 0,
largestOrderId: null,
smallestOrderId: null
},
orderValueDistribution: [
{ min: 0, max: 25, count: 0, total: 0 },
{ min: 25, max: 50, count: 0, total: 0 },
{ min: 50, max: 100, count: 0, total: 0 },
{ min: 100, max: 200, count: 0, total: 0 },
{ min: 200, max: 'Infinity', count: 0, total: 0 }
]
});
currentDate = currentDate.plus({ days: 1 });
}
// Process events
for (const event of transformedEvents) {
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
if (!datetime) continue;
const props = event.event_properties || {};
const totalAmount = Number(props.TotalAmount || 0);
const orderId = props.OrderId;
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
if (dailyStats.has(dateKey)) {
const dayStats = dailyStats.get(dateKey);
dayStats.orders++;
// Update order value range
if (totalAmount > dayStats.orderValueRange.largest) {
dayStats.orderValueRange.largest = totalAmount;
dayStats.orderValueRange.largestOrderId = orderId;
}
if (totalAmount > 0 && (dayStats.orderValueRange.smallest === 0 || totalAmount < dayStats.orderValueRange.smallest)) {
dayStats.orderValueRange.smallest = totalAmount;
dayStats.orderValueRange.smallestOrderId = orderId;
}
// Update distribution
if (totalAmount < 25) {
dayStats.orderValueDistribution[0].count++;
dayStats.orderValueDistribution[0].total += totalAmount;
} else if (totalAmount < 50) {
dayStats.orderValueDistribution[1].count++;
dayStats.orderValueDistribution[1].total += totalAmount;
} else if (totalAmount < 100) {
dayStats.orderValueDistribution[2].count++;
dayStats.orderValueDistribution[2].total += totalAmount;
} else if (totalAmount < 200) {
dayStats.orderValueDistribution[3].count++;
dayStats.orderValueDistribution[3].total += totalAmount;
} else {
dayStats.orderValueDistribution[4].count++;
dayStats.orderValueDistribution[4].total += totalAmount;
}
dayStats.averageOrderValue = dayStats.orderValueDistribution.reduce((sum, range) => sum + range.total, 0) / dayStats.orders;
dailyStats.set(dateKey, dayStats);
}
}
// Convert to array and sort by date
const stats = Array.from(dailyStats.values())
.sort((a, b) => a.date.localeCompare(b.date));
return stats;
}
// For refunds and cancellations, we need to fetch those specific events
if (metric === 'refunds' || metric === 'cancellations') {
const [currentEvents] = await Promise.all([
this.getEvents({
...params,
startDate: periodStart.toISO(),
endDate: periodEnd.toISO(),
metricId: metric === 'refunds' ? METRIC_IDS.PAYMENT_REFUNDED : METRIC_IDS.CANCELED_ORDER
})
]);
// Transform events
const transformedEvents = this._transformEvents(currentEvents.data || []);
console.log(`[EventsService] Processing ${transformedEvents.length} ${metric}`);
// Initialize daily stats map with all dates in range using TimeManager's day start
const dailyStats = new Map();
let currentDate = periodStart;
while (currentDate <= periodEnd) {
const dateKey = currentDate.toFormat('yyyy-MM-dd');
dailyStats.set(dateKey, {
date: currentDate.toISO(),
timestamp: dateKey,
total: 0,
count: 0,
reasons: {},
items: []
});
currentDate = this.timeManager.getDayStart(currentDate.plus({ days: 1 }));
}
// Aggregate all reasons and items for the entire period
const periodStats = {
total: 0,
count: 0,
reasons: {},
items: []
};
// Process current period events
for (const event of transformedEvents) {
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
if (!datetime) continue;
const props = event.event_properties || {};
const amount = Number(metric === 'refunds' ? props.PaymentAmount : props.TotalAmount || 0);
const reason = props.CancelReason || props.OrderMessage || 'No reason provided';
const orderId = props.OrderId || props.FromOrder;
const item = {
orderId,
amount,
reason,
datetime: datetime.toISO()
};
// Always update period totals for events within the period
periodStats.total += amount;
periodStats.count++;
periodStats.reasons[reason] = (periodStats.reasons[reason] || 0) + 1;
periodStats.items.push(item);
// Get the day start for this event's time
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
// Update daily stats if we have this day in our map
if (dailyStats.has(dateKey)) {
const dayStats = dailyStats.get(dateKey);
dayStats.total += amount;
dayStats.count++;
dayStats.reasons[reason] = (dayStats.reasons[reason] || 0) + 1;
dayStats.items.push(item);
dailyStats.set(dateKey, dayStats);
}
}
console.log(`[EventsService] Period stats for ${metric}:`, {
total: periodStats.total,
count: periodStats.count,
reasonCount: Object.keys(periodStats.reasons).length,
itemCount: periodStats.items.length
});
// Convert to array and sort by date
const stats = Array.from(dailyStats.values())
.sort((a, b) => a.date.localeCompare(b.date))
.map(day => ({
...day,
[metric === 'refunds' ? 'refunds' : 'canceledOrders']: {
total: day.total,
count: day.count,
reasons: periodStats.reasons, // Use period-wide reasons for each day
items: day.items,
periodTotal: periodStats.total,
periodCount: periodStats.count,
periodReasons: periodStats.reasons,
periodItems: periodStats.items
}
}));
return stats;
}
// For other metrics, continue with existing logic
const [currentResponse, prevResponse] = await Promise.all([
this.getEvents({
...params,
startDate: periodStart.toISO(),
endDate: periodEnd.toISO(),
metricId: METRIC_IDS.PLACED_ORDER
}),
this.getEvents({
..._.omit(params, ['timeRange', 'startDate', 'endDate']),
startDate: prevPeriodStart.toISO(),
endDate: prevPeriodEnd.toISO(),
metricId: METRIC_IDS.PLACED_ORDER
})
]);
// Transform events
const currentEvents = this._transformEvents(currentResponse.data || []);
const prevEvents = this._transformEvents(prevResponse.data || []);
// Initialize daily stats map with all dates in range using TimeManager's day start
const dailyStats = new Map();
let currentDate = periodStart;
while (currentDate <= periodEnd) {
const dateKey = currentDate.toFormat('yyyy-MM-dd');
dailyStats.set(dateKey, {
date: currentDate.toISO(),
timestamp: dateKey,
revenue: 0,
orders: 0,
itemCount: 0,
count: 0,
value: 0,
percentage: 0,
totalOrders: 0,
prevValue: 0,
prevOrders: 0,
prevItemCount: 0,
prevCount: 0,
prevPercentage: 0,
averageOrderValue: 0,
averageItemsPerOrder: 0,
prevAvgOrderValue: 0
});
currentDate = this.timeManager.getDayStart(currentDate.plus({ days: 1 }));
}
// First pass: Count total orders per day using TimeManager's day boundaries
for (const event of currentEvents) {
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
if (!datetime) continue;
// Get the day start for this event's time to ensure proper day assignment
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
if (!dailyStats.has(dateKey)) continue;
const dayStats = dailyStats.get(dateKey);
dayStats.orders++;
dailyStats.set(dateKey, dayStats);
}
// Second pass: Process filtered orders
const filterEvents = (events) => {
switch (metric) {
case 'pre_orders':
return events.filter(event => Boolean(event.event_properties?.HasPreorder));
case 'local_pickup':
return events.filter(event => Boolean(event.event_properties?.LocalPickup));
case 'on_hold':
return events.filter(event => Boolean(event.event_properties?.IsOnHold));
default:
return events;
}
};
const filteredCurrentEvents = filterEvents(currentEvents);
// Process current period filtered events using TimeManager's day boundaries
for (const event of filteredCurrentEvents) {
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
if (!datetime) continue;
// Get the day start for this event's time
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
if (!dailyStats.has(dateKey)) continue;
const dayStats = dailyStats.get(dateKey);
const props = event.event_properties || {};
const totalAmount = Number(props.TotalAmount || 0);
const items = props.Items || [];
dayStats.count++;
dayStats.value += totalAmount;
dayStats.revenue = dayStats.value;
dayStats.itemCount += items.length;
dayStats.percentage = (dayStats.count / dayStats.orders) * 100;
dayStats.averageOrderValue = dayStats.value / dayStats.count;
dayStats.averageItemsPerOrder = dayStats.itemCount / dayStats.count;
dailyStats.set(dateKey, dayStats);
}
// Initialize and process previous period stats using TimeManager's day boundaries
const prevDailyStats = new Map();
let prevDate = prevPeriodStart;
while (prevDate <= prevPeriodEnd) {
const dateKey = prevDate.toFormat('yyyy-MM-dd');
prevDailyStats.set(dateKey, {
date: prevDate.toISO(),
timestamp: dateKey,
orders: 0,
count: 0,
value: 0,
percentage: 0
});
prevDate = this.timeManager.getDayStart(prevDate.plus({ days: 1 }));
}
// First pass for previous period: Count total orders
for (const event of prevEvents) {
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
if (!datetime) continue;
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
if (!prevDailyStats.has(dateKey)) continue;
const dayStats = prevDailyStats.get(dateKey);
dayStats.orders++;
prevDailyStats.set(dateKey, dayStats);
}
// Second pass for previous period: Process filtered orders
const filteredPrevEvents = filterEvents(prevEvents);
for (const event of filteredPrevEvents) {
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
if (!datetime) continue;
const dayStart = this.timeManager.getDayStart(datetime);
const dateKey = dayStart.toFormat('yyyy-MM-dd');
if (!prevDailyStats.has(dateKey)) continue;
const dayStats = prevDailyStats.get(dateKey);
const props = event.event_properties || {};
const totalAmount = Number(props.TotalAmount || 0);
dayStats.count++;
dayStats.value += totalAmount;
dayStats.percentage = (dayStats.count / dayStats.orders) * 100;
prevDailyStats.set(dateKey, dayStats);
}
// Map previous period data to current period days
const prevPeriodDays = Array.from(prevDailyStats.values()).sort((a, b) => a.date.localeCompare(b.date));
const currentPeriodDays = Array.from(dailyStats.values()).sort((a, b) => a.date.localeCompare(b.date));
// Map the data using array indices
for (let i = 0; i < currentPeriodDays.length && i < prevPeriodDays.length; i++) {
const currentDayStats = currentPeriodDays[i];
const prevDayStats = prevPeriodDays[i];
if (prevDayStats && currentDayStats) {
const dayStats = dailyStats.get(currentDayStats.timestamp);
if (dayStats) {
dayStats.prevValue = prevDayStats.value;
dayStats.prevRevenue = prevDayStats.value;
dayStats.prevCount = prevDayStats.count;
dayStats.prevOrders = prevDayStats.orders;
dayStats.prevPercentage = prevDayStats.percentage;
dayStats.prevAvgOrderValue = prevDayStats.count > 0 ? prevDayStats.value / prevDayStats.count : 0;
dailyStats.set(currentDayStats.timestamp, dayStats);
}
}
}
// Convert to array and sort by date
const stats = Array.from(dailyStats.values())
.sort((a, b) => a.date.localeCompare(b.date))
.map(day => ({
...day,
revenue: Number(day.revenue || day.value || 0),
orders: Number(day.orders || 0),
itemCount: Number(day.itemCount || 0),
count: Number(day.count || 0),
value: Number(day.value || 0),
percentage: Number(day.percentage || 0),
averageOrderValue: Number(day.averageOrderValue || 0),
averageItemsPerOrder: Number(day.averageItemsPerOrder || 0),
prevRevenue: Number(day.prevRevenue || day.prevValue || 0),
prevValue: Number(day.prevValue || 0),
prevCount: Number(day.prevCount || 0),
prevOrders: Number(day.prevOrders || 0),
prevPercentage: Number(day.prevPercentage || 0),
prevAvgOrderValue: Number(day.prevAvgOrderValue || 0)
}));
return stats;
} catch (error) {
console.error('[EventsService] Error calculating detailed stats:', error);
throw error;
}
}
_getColorForBrand(brandName) {
// Generate a consistent color based on the brand name
let hash = 0;
for (let i = 0; i < brandName.length; i++) {
hash = brandName.charCodeAt(i) + ((hash << 5) - hash);
}
// Use HSL to ensure colors are visually distinct and pleasing
const hue = Math.abs(hash % 360);
return `hsl(${hue}, 70%, 50%)`;
}
_getColorForCategory(categoryName) {
// Generate a consistent color based on the category name
let hash = 0;
for (let i = 0; i < categoryName.length; i++) {
hash = categoryName.charCodeAt(i) + ((hash << 5) - hash);
}
// Use HSL with different saturation/lightness than brands
const hue = Math.abs(hash % 360);
return `hsl(${hue}, 60%, 60%)`;
}
async getBatchMetrics(params = {}) {
try {
const { timeRange, startDate, endDate, metrics = [] } = params;
// Create a map of all metric requests
const metricPromises = metrics.map(metric => {
switch(metric) {
case 'orders':
return this.getEvents({
...params,
metricId: METRIC_IDS.PLACED_ORDER
});
case 'revenue':
return this.getEvents({
...params,
metricId: METRIC_IDS.PLACED_ORDER,
property: 'TotalAmount'
});
case 'refunds':
return this.getEvents({
...params,
metricId: METRIC_IDS.PAYMENT_REFUNDED
});
case 'cancellations':
return this.getEvents({
...params,
metricId: METRIC_IDS.CANCELED_ORDER
});
case 'shipping':
return this.getEvents({
...params,
metricId: METRIC_IDS.SHIPPED_ORDER
});
default:
return Promise.resolve(null);
}
});
// Execute all promises in parallel
const results = await Promise.all(metricPromises);
// Transform results into a keyed object
const batchResults = {};
metrics.forEach((metric, index) => {
batchResults[metric] = results[index];
});
return batchResults;
} catch (error) {
console.error('[EventsService] Error in batch metrics:', error);
throw error;
}
}
async calculateSmartProjection(params = {}) {
try {
const { timeRange, startDate, endDate } = params;
// Get current period dates
let periodStart, periodEnd;
if (startDate && endDate) {
periodStart = this.timeManager.getDayStart(this.timeManager.toDateTime(startDate));
periodEnd = this.timeManager.getDayEnd(this.timeManager.toDateTime(endDate));
} else if (timeRange) {
const range = this.timeManager.getDateRange(timeRange);
periodStart = range.start;
periodEnd = range.end;
}
// Get the same day of week from the last 4 weeks for pattern matching
const historicalPeriods = [];
let historicalStart = periodStart.minus({ weeks: 4 });
for (let i = 0; i < 4; i++) {
historicalPeriods.push({
start: historicalStart.plus({ weeks: i }),
end: historicalStart.plus({ weeks: i + 1 }).minus({ milliseconds: 1 })
});
}
// Fetch current period data
const currentEvents = await this.getEvents({
startDate: periodStart.toISO(),
endDate: periodEnd.toISO(),
metricId: METRIC_IDS.PLACED_ORDER
});
// Fetch historical data for pattern matching
const historicalPromises = historicalPeriods.map(period =>
this.getEvents({
startDate: period.start.toISO(),
endDate: period.end.toISO(),
metricId: METRIC_IDS.PLACED_ORDER
})
);
const historicalResults = await Promise.all(historicalPromises);
// Process current period data
const currentData = this._transformEvents(currentEvents.data || []);
const currentRevenue = currentData.reduce((sum, event) => {
const props = event.event_properties || {};
return sum + Number(props.TotalAmount || 0);
}, 0);
// Build hourly patterns from historical data
const hourlyPatterns = Array(24).fill(0).map(() => ({
count: 0,
revenue: 0,
percentage: 0
}));
let totalHistoricalRevenue = 0;
let totalHistoricalOrders = 0;
historicalResults.forEach(result => {
const events = this._transformEvents(result.data || []);
events.forEach(event => {
const datetime = this.timeManager.toDateTime(event.attributes?.datetime);
if (!datetime) return;
const hour = datetime.hour;
const props = event.event_properties || {};
const amount = Number(props.TotalAmount || 0);
hourlyPatterns[hour].count++;
hourlyPatterns[hour].revenue += amount;
totalHistoricalRevenue += amount;
totalHistoricalOrders++;
});
});
// Calculate percentages
hourlyPatterns.forEach(pattern => {
pattern.percentage = totalHistoricalRevenue > 0 ?
(pattern.revenue / totalHistoricalRevenue) * 100 : 0;
});
// Get current hour in the period's timezone
const now = this.timeManager.getNow();
const currentHour = now.hour;
const currentMinute = now.minute;
// Handle the 12-1 AM edge case
const isInEdgeCase = currentHour < this.timeManager.dayStartHour;
const adjustedCurrentHour = isInEdgeCase ? currentHour + 24 : currentHour;
const adjustedDayStartHour = this.timeManager.dayStartHour;
// Calculate how much of the current hour has passed (0-1)
const hourProgress = currentMinute / 60;
// Calculate how much of the expected daily revenue we've seen so far
let expectedPercentageSeen = 0;
let totalDayPercentage = 0;
// First, calculate total percentage for a full day
for (let i = 0; i < 24; i++) {
totalDayPercentage += hourlyPatterns[i].percentage;
}
if (isInEdgeCase) {
// If we're between 12-1 AM, we want to use almost the full day's percentage
// since we're at the end of the previous day
expectedPercentageSeen = totalDayPercentage;
// Subtract the remaining portion of the current hour
expectedPercentageSeen -= hourlyPatterns[currentHour].percentage * (1 - hourProgress);
} else {
// Normal case - add up percentages from day start to current hour
for (let i = adjustedDayStartHour; i < adjustedCurrentHour; i++) {
const hourIndex = i % 24;
expectedPercentageSeen += hourlyPatterns[hourIndex].percentage;
}
// Add partial current hour
expectedPercentageSeen += hourlyPatterns[currentHour].percentage * hourProgress;
}
// Calculate projection based on patterns
let projectedRevenue = 0;
if (expectedPercentageSeen > 0) {
projectedRevenue = (currentRevenue / (expectedPercentageSeen / totalDayPercentage));
}
// Calculate confidence score (0-1) based on:
// 1. How much historical data we have
// 2. How consistent the patterns are
// 3. How far through the period we are
const patternConsistency = this._calculatePatternConsistency(hourlyPatterns);
// Calculate period progress considering the 1 AM day start
const totalDuration = periodEnd.diff(periodStart);
const elapsedDuration = now.diff(periodStart);
let periodProgress = Math.min(100, Math.max(0, (elapsedDuration.milliseconds / totalDuration.milliseconds) * 100));
// Adjust period progress for the 12-1 AM edge case
if (isInEdgeCase) {
// If we're between 12-1 AM, we're actually at the end of the previous day
periodProgress = Math.min(100, Math.max(0, ((24 - adjustedDayStartHour + currentHour) / 24) * 100));
}
const historicalDataAmount = Math.min(totalHistoricalOrders / 1000, 1);
const confidence = (
(patternConsistency * 0.4) +
(periodProgress / 100 * 0.4) +
(historicalDataAmount * 0.2)
);
// Return both the simple and pattern-based projections with metadata
return {
currentRevenue,
projectedRevenue,
confidence,
metadata: {
periodProgress,
patternConsistency,
historicalOrders: totalHistoricalOrders,
hourlyPatterns,
expectedPercentageSeen,
totalDayPercentage,
currentHour,
currentMinute,
isInEdgeCase,
adjustedCurrentHour
}
};
} catch (error) {
console.error('[EventsService] Error calculating smart projection:', error);
throw error;
}
}
_calculatePatternConsistency(hourlyPatterns) {
// Calculate the standard deviation of the percentage distribution
const mean = hourlyPatterns.reduce((sum, pattern) => sum + pattern.percentage, 0) / 24;
const variance = hourlyPatterns.reduce((sum, pattern) => {
const diff = pattern.percentage - mean;
return sum + (diff * diff);
}, 0) / 24;
const stdDev = Math.sqrt(variance);
// Normalize to a 0-1 scale where lower standard deviation means higher consistency
// Using a sigmoid function to normalize
const normalizedConsistency = 1 / (1 + Math.exp(stdDev / 10));
return normalizedConsistency;
}
}