Files
inventory/inventory-server/dashboard/acot-server/routes/events.js

1798 lines
62 KiB
JavaScript

const express = require('express');
const { DateTime } = require('luxon');
const router = express.Router();
const { getDbConnection, getPoolStatus } = require('../db/connection');
const {
getTimeRangeConditions,
formatBusinessDate,
getBusinessDayBounds,
_internal: timeHelpers
} = require('../utils/timeUtils');
const TIMEZONE = 'America/New_York';
const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1;
// Cherry Box order types to exclude when excludeCherryBox=true is passed
// 3 = cherrybox_subscription, 4 = cherrybox_sending, 5 = cherrybox_subscription_renew, 7 = cherrybox_refund
const EXCLUDED_ORDER_TYPES = [3, 4, 5, 7];
const getCherryBoxClause = (exclude) => exclude ? `order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
const getCherryBoxClauseAliased = (alias, exclude) => exclude ? `${alias}.order_type NOT IN (${EXCLUDED_ORDER_TYPES.join(', ')})` : '1=1';
const parseBoolParam = (value) => value === 'true' || value === '1';
// Image URL generation utility
const getImageUrls = (pid, iid = 1) => {
const imageUrlBase = 'https://sbing.com/i/products/0000/';
const paddedPid = pid.toString().padStart(6, '0');
const prefix = paddedPid.slice(0, 3);
const basePath = `${imageUrlBase}${prefix}/${pid}`;
return {
image: `${basePath}-t-${iid}.jpg`,
image_175: `${basePath}-175x175-${iid}.jpg`,
image_full: `${basePath}-o-${iid}.jpg`,
ImgThumb: `${basePath}-175x175-${iid}.jpg` // For ProductGrid component
};
};
// Main stats endpoint - replaces /api/klaviyo/events/stats
router.get('/stats', async (req, res) => {
const startTime = Date.now();
console.log(`[STATS] Starting request for timeRange: ${req.query.timeRange}`);
// Set a timeout for the entire operation
const timeoutPromise = new Promise((_, reject) => {
setTimeout(() => reject(new Error('Request timeout after 15 seconds')), 15000);
});
try {
const mainOperation = async () => {
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
console.log(`[STATS] Getting DB connection... (excludeCherryBox: ${excludeCB})`);
const { connection, release } = await getDbConnection();
console.log(`[STATS] DB connection obtained in ${Date.now() - startTime}ms`);
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
// Main order stats query (optionally excludes Cherry Box orders)
// Note: order_status > 15 excludes cancelled (15), so cancelled stats are queried separately
const mainStatsQuery = `
SELECT
COUNT(*) as orderCount,
SUM(summary_total) as revenue,
SUM(stats_prod_pieces) as itemCount,
AVG(summary_total) as averageOrderValue,
AVG(stats_prod_pieces) as averageItemsPerOrder,
SUM(CASE WHEN stats_waiting_preorder > 0 THEN 1 ELSE 0 END) as preOrderCount,
SUM(CASE WHEN ship_method_selected = 'localpickup' THEN 1 ELSE 0 END) as localPickupCount,
SUM(CASE WHEN ship_method_selected = 'holdit' THEN 1 ELSE 0 END) as onHoldCount
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`;
const [mainStats] = await connection.execute(mainStatsQuery, params);
const stats = mainStats[0];
// Cancelled orders query - uses date_cancelled instead of date_placed
// Shows orders cancelled during the selected period, regardless of when they were placed
const cancelledQuery = `
SELECT
COUNT(*) as cancelledCount,
SUM(summary_total) as cancelledTotal
FROM _order
WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_cancelled')}
`;
const [cancelledResult] = await connection.execute(cancelledQuery, params);
const cancelledStats = cancelledResult[0] || { cancelledCount: 0, cancelledTotal: 0 };
// Refunds query (optionally excludes Cherry Box orders)
const refundsQuery = `
SELECT
COUNT(*) as refundCount,
ABS(SUM(payment_amount)) as refundTotal
FROM order_payment op
JOIN _order o ON op.order_id = o.order_id
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
`;
const [refundStats] = await connection.execute(refundsQuery, params);
// Shipped orders query - uses date_shipped instead of date_placed
// This counts orders that were SHIPPED during the selected period, regardless of when they were placed
const shippedQuery = `
SELECT COUNT(*) as shippedCount
FROM _order
WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_shipped')}
`;
const [shippedResult] = await connection.execute(shippedQuery, params);
const shippedCount = parseInt(shippedResult[0]?.shippedCount || 0);
// Best revenue day query (optionally excludes Cherry Box orders)
const bestDayQuery = `
SELECT
DATE(date_placed) as date,
SUM(summary_total) as revenue,
COUNT(*) as orders
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
GROUP BY DATE(date_placed)
ORDER BY revenue DESC
LIMIT 1
`;
const [bestDayResult] = await connection.execute(bestDayQuery, params);
// Peak hour query - uses selected time range for the card value
let peakHour = null;
if (['today', 'yesterday'].includes(timeRange)) {
const peakHourQuery = `
SELECT
HOUR(date_placed) as hour,
COUNT(*) as count
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
GROUP BY HOUR(date_placed)
ORDER BY count DESC
LIMIT 1
`;
const [peakHourResult] = await connection.execute(peakHourQuery, params);
if (peakHourResult.length > 0) {
const hour = peakHourResult[0].hour;
const date = new Date();
date.setHours(hour, 0, 0);
peakHour = {
hour,
count: parseInt(peakHourResult[0].count),
displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
};
}
}
// Hourly breakdown for detail chart - always rolling 24 hours (like revenue/orders use 30 days)
// Returns data ordered chronologically: [24hrs ago, 23hrs ago, ..., 1hr ago, current hour]
let hourlyOrders = null;
if (['today', 'yesterday'].includes(timeRange)) {
// Get hourly counts AND current hour from MySQL to avoid timezone mismatch
const hourlyQuery = `
SELECT
HOUR(date_placed) as hour,
COUNT(*) as count,
HOUR(NOW()) as currentHour
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCB)}
AND date_placed >= NOW() - INTERVAL 24 HOUR
GROUP BY HOUR(date_placed)
`;
const [hourlyResult] = await connection.execute(hourlyQuery);
// Get current hour from MySQL (same timezone as the WHERE clause)
const currentHour = hourlyResult.length > 0 ? parseInt(hourlyResult[0].currentHour) : new Date().getHours();
// Build map of hour -> count
const hourCounts = {};
hourlyResult.forEach(row => {
hourCounts[parseInt(row.hour)] = parseInt(row.count);
});
// Build array in chronological order starting from (currentHour + 1) which is 24 hours ago
hourlyOrders = [];
for (let i = 0; i < 24; i++) {
const hour = (currentHour + 1 + i) % 24; // Start from 24hrs ago, end at current hour
hourlyOrders.push({
hour,
count: hourCounts[hour] || 0
});
}
}
// Brands query - products.company links to product_categories.cat_id for brand name
// Only include products that have a brand assigned (INNER JOIN)
const brandsQuery = `
SELECT
pc.cat_id as catId,
pc.name as brandName,
COUNT(DISTINCT oi.order_id) as orderCount,
SUM(oi.qty_ordered) as itemCount,
SUM(oi.qty_ordered * oi.prod_price) as revenue
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
JOIN product_categories pc ON p.company = pc.cat_id
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
GROUP BY pc.cat_id, pc.name
HAVING revenue > 0
ORDER BY revenue DESC
LIMIT 100
`;
const [brandsResult] = await connection.execute(brandsQuery, params);
// Categories query - uses product_category_index to get category assignments
// Only include categories with valid types (no NULL/uncategorized)
const categoriesQuery = `
SELECT
pc.cat_id as catId,
pc.name as categoryName,
COUNT(DISTINCT oi.order_id) as orderCount,
SUM(oi.qty_ordered) as itemCount,
SUM(oi.qty_ordered * oi.prod_price) as revenue
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
JOIN product_category_index pci ON p.pid = pci.pid
JOIN product_categories pc ON pci.cat_id = pc.cat_id
WHERE o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)}
AND ${whereClause.replace('date_placed', 'o.date_placed')}
AND pc.type IN (10, 20, 11, 21, 12, 13)
GROUP BY pc.cat_id, pc.name
HAVING revenue > 0
ORDER BY revenue DESC
LIMIT 100
`;
const [categoriesResult] = await connection.execute(categoriesQuery, params);
// Shipping locations query - uses date_shipped to match shippedCount
const shippingQuery = `
SELECT
ship_country,
ship_state,
ship_method_selected,
COUNT(*) as count
FROM _order
WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_shipped')}
GROUP BY ship_country, ship_state, ship_method_selected
`;
const [shippingResult] = await connection.execute(shippingQuery, params);
// Process shipping data
const shippingStats = processShippingData(shippingResult, shippedCount);
// Order value range query (optionally excludes Cherry Box orders)
// Excludes $0 orders from min calculation
const orderRangeQuery = `
SELECT
MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest,
MAX(summary_total) as largest
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`;
const [orderRangeResult] = await connection.execute(orderRangeQuery, params);
// Calculate period progress for incomplete periods
let periodProgress = 100;
if (['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
periodProgress = calculatePeriodProgress(timeRange);
}
// Previous period comparison data
const prevPeriodData = await getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCB);
const response = {
timeRange: dateRange,
stats: {
revenue: parseFloat(stats.revenue || 0),
orderCount: parseInt(stats.orderCount || 0),
itemCount: parseInt(stats.itemCount || 0),
averageOrderValue: parseFloat(stats.averageOrderValue || 0),
averageItemsPerOrder: parseFloat(stats.averageItemsPerOrder || 0),
// Order types
orderTypes: {
preOrders: {
count: parseInt(stats.preOrderCount || 0),
percentage: stats.orderCount > 0 ? (stats.preOrderCount / stats.orderCount) * 100 : 0
},
localPickup: {
count: parseInt(stats.localPickupCount || 0),
percentage: stats.orderCount > 0 ? (stats.localPickupCount / stats.orderCount) * 100 : 0
},
heldItems: {
count: parseInt(stats.onHoldCount || 0),
percentage: stats.orderCount > 0 ? (stats.onHoldCount / stats.orderCount) * 100 : 0
}
},
// Shipping
shipping: {
shippedCount: parseInt(shippedCount || 0),
locations: shippingStats.locations,
methodStats: shippingStats.methods
},
// Brands and categories
brands: {
total: brandsResult.length,
list: brandsResult.slice(0, 50).map(brand => ({
id: brand.catId,
name: brand.brandName,
count: parseInt(brand.itemCount),
revenue: parseFloat(brand.revenue)
}))
},
categories: {
total: categoriesResult.length,
list: categoriesResult.slice(0, 50).map(category => ({
id: category.catId,
name: category.categoryName,
count: parseInt(category.itemCount),
revenue: parseFloat(category.revenue)
}))
},
// Refunds and cancellations
refunds: {
total: parseFloat(refundStats[0]?.refundTotal || 0),
count: parseInt(refundStats[0]?.refundCount || 0)
},
canceledOrders: {
total: parseFloat(cancelledStats.cancelledTotal || 0),
count: parseInt(cancelledStats.cancelledCount || 0)
},
// Best day
bestRevenueDay: bestDayResult.length > 0 ? {
amount: parseFloat(bestDayResult[0].revenue),
displayDate: bestDayResult[0].date,
orders: parseInt(bestDayResult[0].orders)
} : null,
// Peak hour (for single days)
peakOrderHour: peakHour,
hourlyOrders: hourlyOrders, // Array of 24 hourly order counts for the detail chart
// Order value range
orderValueRange: orderRangeResult.length > 0 ? {
smallest: parseFloat(orderRangeResult[0].smallest || 0),
largest: parseFloat(orderRangeResult[0].largest || 0)
} : { smallest: 0, largest: 0 },
// Period progress and projections
periodProgress,
projectedRevenue: periodProgress < 100 ? (stats.revenue / (periodProgress / 100)) : stats.revenue,
// Previous period comparison
prevPeriodRevenue: prevPeriodData.revenue,
prevPeriodOrders: prevPeriodData.orderCount,
prevPeriodAOV: prevPeriodData.averageOrderValue
}
};
return { response, release };
};
// Race between the main operation and timeout
let result;
try {
result = await Promise.race([mainOperation(), timeoutPromise]);
} catch (error) {
// If it's a timeout, we don't have a release function to call
if (error.message.includes('timeout')) {
console.log(`[STATS] Request timed out in ${Date.now() - startTime}ms`);
throw error;
}
// For other errors, re-throw
throw error;
}
const { response, release } = result;
// Release connection back to pool
if (release) release();
console.log(`[STATS] Request completed in ${Date.now() - startTime}ms`);
res.json(response);
} catch (error) {
console.error('Error in /stats:', error);
console.log(`[STATS] Request failed in ${Date.now() - startTime}ms`);
res.status(500).json({ error: error.message });
}
});
// Daily details endpoint - replaces /api/klaviyo/events/stats/details
router.get('/stats/details', async (req, res) => {
let release;
try {
const { timeRange, startDate, endDate, metric, daily, excludeCherryBox, orderType, eventType } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
// Handle special event types (refunds, cancellations)
if (eventType === 'PAYMENT_REFUNDED') {
// Refunds query - from order_payment table
const refundsQuery = `
SELECT
DATE(op.payment_date) as date,
COUNT(*) as count,
ABS(SUM(op.payment_amount)) as total
FROM order_payment op
JOIN _order o ON op.order_id = o.order_id
WHERE op.payment_amount < 0
AND o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)}
AND ${whereClause.replace('date_placed', 'op.payment_date')}
GROUP BY DATE(op.payment_date)
ORDER BY DATE(op.payment_date)
`;
const [refundResults] = await connection.execute(refundsQuery, params);
// Format matches what frontend expects: day.refunds.total, day.refunds.count
const stats = refundResults.map(day => ({
timestamp: day.date,
date: day.date,
refunds: {
total: parseFloat(day.total || 0),
count: parseInt(day.count || 0),
reasons: {}
}
}));
if (release) release();
return res.json({ stats });
}
if (eventType === 'CANCELED_ORDER') {
// Cancellations query - uses date_cancelled to show when orders were actually cancelled
const cancelQuery = `
SELECT
DATE(date_cancelled) as date,
COUNT(*) as count,
SUM(summary_total) as total
FROM _order
WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_cancelled')}
GROUP BY DATE(date_cancelled)
ORDER BY DATE(date_cancelled)
`;
const [cancelResults] = await connection.execute(cancelQuery, params);
// Format matches what frontend expects: day.canceledOrders.total, day.canceledOrders.count
const stats = cancelResults.map(day => ({
timestamp: day.date,
date: day.date,
canceledOrders: {
total: parseFloat(day.total || 0),
count: parseInt(day.count || 0),
reasons: {}
}
}));
if (release) release();
return res.json({ stats });
}
if (eventType === 'PLACED_ORDER') {
// Order range query - daily min/max/average order values
const orderRangeQuery = `
SELECT
DATE(date_placed) as date,
COUNT(*) as orders,
MIN(CASE WHEN summary_total > 0 THEN summary_total END) as smallest,
MAX(summary_total) as largest,
AVG(summary_total) as averageOrderValue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause}
GROUP BY DATE(date_placed)
ORDER BY DATE(date_placed)
`;
const [orderRangeResults] = await connection.execute(orderRangeQuery, params);
// Format matches what frontend OrderRangeDetails expects
const stats = orderRangeResults.map(day => ({
timestamp: day.date,
date: day.date,
orders: parseInt(day.orders || 0),
orderValueRange: {
smallest: parseFloat(day.smallest || 0),
largest: parseFloat(day.largest || 0)
},
averageOrderValue: parseFloat(day.averageOrderValue || 0)
}));
if (release) release();
return res.json({ stats });
}
// Build order type filter based on orderType parameter
let orderTypeFilter = '';
if (orderType === 'pre_orders') {
orderTypeFilter = 'AND stats_waiting_preorder > 0';
} else if (orderType === 'local_pickup') {
orderTypeFilter = "AND ship_method_selected = 'localpickup'";
} else if (orderType === 'on_hold') {
orderTypeFilter = "AND ship_method_selected = 'holdit'";
}
// Daily breakdown query (optionally excludes Cherry Box orders)
const dailyQuery = `
SELECT
DATE(date_placed) as date,
COUNT(*) as orders,
SUM(summary_total) as revenue,
AVG(summary_total) as averageOrderValue,
SUM(stats_prod_pieces) as itemCount
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause} ${orderTypeFilter}
GROUP BY DATE(date_placed)
ORDER BY DATE(date_placed)
`;
const [dailyResults] = await connection.execute(dailyQuery, params);
// Get previous period data using the same logic as main stats endpoint
let prevWhereClause, prevParams;
if (timeRange && timeRange !== 'custom') {
const prevTimeRange = getPreviousTimeRange(timeRange);
const result = getTimeRangeConditions(prevTimeRange);
prevWhereClause = result.whereClause;
prevParams = result.params;
} else {
// Custom date range - go back by the same duration
const start = new Date(startDate);
const end = new Date(endDate);
const duration = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
prevWhereClause = 'date_placed >= ? AND date_placed <= ?';
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
}
// Get previous period daily data (optionally excludes Cherry Box orders)
const prevQuery = `
SELECT
DATE(date_placed) as date,
COUNT(*) as prevOrders,
SUM(summary_total) as prevRevenue,
AVG(summary_total) as prevAvgOrderValue
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${prevWhereClause} ${orderTypeFilter}
GROUP BY DATE(date_placed)
`;
const [prevResults] = await connection.execute(prevQuery, prevParams);
// Create a map for quick lookup of previous period data
const prevMap = new Map();
prevResults.forEach(prev => {
const key = new Date(prev.date).toISOString().split('T')[0];
prevMap.set(key, prev);
});
// For period-to-period comparison, we need to map days by relative position
// since dates won't match exactly (e.g., current week vs previous week)
const dailyArray = dailyResults.map(day => ({
timestamp: day.date,
date: day.date,
orders: parseInt(day.orders),
revenue: parseFloat(day.revenue),
averageOrderValue: parseFloat(day.averageOrderValue || 0),
itemCount: parseInt(day.itemCount)
}));
const prevArray = prevResults.map(day => ({
orders: parseInt(day.prevOrders),
revenue: parseFloat(day.prevRevenue),
averageOrderValue: parseFloat(day.prevAvgOrderValue || 0)
}));
// Combine current and previous period data by matching relative positions
const statsWithComparison = dailyArray.map((day, index) => {
const prev = prevArray[index] || { orders: 0, revenue: 0, averageOrderValue: 0 };
return {
...day,
prevOrders: prev.orders,
prevRevenue: prev.revenue,
prevAvgOrderValue: prev.averageOrderValue
};
});
res.json({ stats: statsWithComparison });
} catch (error) {
console.error('Error in /stats/details:', error);
res.status(500).json({ error: error.message });
} finally {
// Release connection back to pool
if (release) release();
}
});
// Financial performance endpoint
router.get('/financials', async (req, res) => {
let release;
try {
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
const financialWhere = whereClause.replace(/date_placed/g, 'date_change');
const formatDebugBound = (value) => {
if (!value) return 'n/a';
const parsed = DateTime.fromSQL(value, { zone: 'UTC-05:00' });
if (!parsed.isValid) {
return `invalid(${value})`;
}
return parsed.setZone(TIMEZONE).toISO();
};
console.log('[FINANCIALS] request params', {
timeRange: timeRange || 'default',
startDate,
endDate,
whereClause: financialWhere,
params,
boundsEastern: Array.isArray(params) ? params.map(formatDebugBound) : [],
});
const [totalsRows] = await connection.execute(
buildFinancialTotalsQuery(financialWhere, excludeCB),
params
);
const totals = normalizeFinancialTotals(totalsRows[0]);
console.log('[FINANCIALS] totals query result', {
rows: totalsRows.length,
totals,
});
const [trendRows] = await connection.execute(
buildFinancialTrendQuery(financialWhere, excludeCB),
params
);
const trend = trendRows.map(normalizeFinancialTrendRow);
console.log('[FINANCIALS] trend query result', {
rows: trendRows.length,
first: trend[0] || null,
last: trend[trend.length - 1] || null,
});
let previousTotals = null;
let comparison = null;
const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate);
if (previousRange) {
console.log('[FINANCIALS] previous range params', {
timeRange: timeRange || 'default',
prevWhere: previousRange.whereClause.replace(/date_placed/g, 'date_change'),
params: previousRange.params,
boundsEastern: Array.isArray(previousRange.params)
? previousRange.params.map(formatDebugBound)
: [],
});
const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change');
const [previousRows] = await connection.execute(
buildFinancialTotalsQuery(prevWhere, excludeCB),
previousRange.params
);
previousTotals = normalizeFinancialTotals(previousRows[0]);
comparison = {
grossSales: calculateComparison(totals.grossSales, previousTotals.grossSales),
refunds: calculateComparison(totals.refunds, previousTotals.refunds),
taxCollected: calculateComparison(totals.taxCollected, previousTotals.taxCollected),
discounts: calculateComparison(totals.discounts, previousTotals.discounts),
cogs: calculateComparison(totals.cogs, previousTotals.cogs),
income: calculateComparison(totals.income, previousTotals.income),
profit: calculateComparison(totals.profit, previousTotals.profit),
margin: calculateComparison(totals.margin, previousTotals.margin),
};
}
const trendDebugSample = trend.slice(-3).map((item) => ({
date: item.date,
timestamp: item.timestamp,
income: item.income,
grossSales: item.grossSales,
}));
const debugInfo = {
serverTimeUtc: new Date().toISOString(),
timeRange: timeRange || 'default',
params,
boundsEastern: Array.isArray(params) ? params.map(formatDebugBound) : [],
trendCount: trend.length,
trendSample: trendDebugSample,
previousRange: previousRange
? {
params: previousRange.params,
boundsEastern: Array.isArray(previousRange.params)
? previousRange.params.map(formatDebugBound)
: [],
}
: null,
};
res.json({
dateRange,
totals,
previousTotals,
comparison,
trend,
debug: debugInfo,
});
} catch (error) {
console.error('Error in /financials:', error);
res.status(500).json({ error: error.message });
} finally {
if (release) release();
}
});
// Products endpoint - replaces /api/klaviyo/events/products
router.get('/products', async (req, res) => {
let release;
try {
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
// Products query (optionally excludes Cherry Box orders)
const productsQuery = `
SELECT
p.pid,
p.description as name,
SUM(oi.qty_ordered) as totalQuantity,
SUM(oi.qty_ordered * oi.prod_price) as totalRevenue,
COUNT(DISTINCT oi.order_id) as orderCount,
(SELECT pi.iid FROM product_images pi WHERE pi.pid = p.pid AND pi.order = 255 LIMIT 1) as primary_iid
FROM order_items oi
JOIN _order o ON oi.order_id = o.order_id
JOIN products p ON oi.prod_pid = p.pid
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace('date_placed', 'o.date_placed')}
GROUP BY p.pid, p.description
ORDER BY totalRevenue DESC
LIMIT 500
`;
const [productsResult] = await connection.execute(productsQuery, params);
// Add image URLs to each product
const productsWithImages = productsResult.map(product => {
const imageUrls = getImageUrls(product.pid, product.primary_iid || 1);
return {
id: product.pid,
name: product.name,
totalQuantity: parseInt(product.totalQuantity),
totalRevenue: parseFloat(product.totalRevenue),
orderCount: parseInt(product.orderCount),
...imageUrls
};
});
res.json({
stats: {
products: {
total: productsWithImages.length,
list: productsWithImages
}
}
});
} catch (error) {
console.error('Error in /products:', error);
res.status(500).json({ error: error.message });
} finally {
// Release connection back to pool
if (release) release();
}
});
// Projection endpoint - replaces /api/klaviyo/events/projection
router.get('/projection', async (req, res) => {
const startTime = Date.now();
let release;
try {
const { timeRange, startDate, endDate, excludeCherryBox } = req.query;
const excludeCB = parseBoolParam(excludeCherryBox);
console.log(`[PROJECTION] Starting request for timeRange: ${timeRange}`);
// Only provide projections for incomplete periods
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
return res.json({ projectedRevenue: 0, confidence: 0, method: 'none' });
}
const { connection, release: releaseConn } = await getDbConnection();
release = releaseConn;
console.log(`[PROJECTION] DB connection obtained in ${Date.now() - startTime}ms`);
const now = DateTime.now().setZone(TIMEZONE);
// Get current period data
const { whereClause, params } = getTimeRangeConditions(timeRange, startDate, endDate);
// Current period query (optionally excludes Cherry Box orders)
const currentQuery = `
SELECT
SUM(summary_total) as currentRevenue,
COUNT(*) as currentOrders
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
`;
const [currentResult] = await connection.execute(currentQuery, params);
const current = currentResult[0];
console.log(`[PROJECTION] Current period data fetched in ${Date.now() - startTime}ms`);
// Fetch pattern data in parallel for performance
const patternStart = Date.now();
const [hourlyPattern, dayOfWeekPattern, dailyStats] = await Promise.all([
getHourlyRevenuePattern(connection, excludeCB),
getDayOfWeekRevenuePattern(connection, excludeCB),
getAverageDailyRevenue(connection, excludeCB)
]);
console.log(`[PROJECTION] Pattern data fetched in ${Date.now() - patternStart}ms`);
// Calculate period progress (for logging/debugging)
const periodProgress = calculatePeriodProgress(timeRange);
// Calculate pattern-based projection
const projection = calculateSmartProjection(
timeRange,
parseFloat(current.currentRevenue || 0),
parseInt(current.currentOrders || 0),
periodProgress,
hourlyPattern,
dayOfWeekPattern,
dailyStats,
now
);
// Add some useful debug info
projection.periodProgress = periodProgress;
projection.currentRevenue = parseFloat(current.currentRevenue || 0);
projection.currentOrders = parseInt(current.currentOrders || 0);
console.log(`[PROJECTION] Request completed in ${Date.now() - startTime}ms - method: ${projection.method}, projected: $${projection.projectedRevenue?.toFixed(2)}`);
res.json(projection);
} catch (error) {
console.error(`[PROJECTION] Error after ${Date.now() - startTime}ms:`, error);
res.status(500).json({ error: error.message });
} finally {
// Release connection back to pool
if (release) release();
}
});
// Debug endpoint to check connection pool status
router.get('/debug/pool', (req, res) => {
res.json(getPoolStatus());
});
// Health check endpoint
router.get('/health', async (req, res) => {
try {
const { connection, release } = await getDbConnection();
// Simple query to test connection
const [result] = await connection.execute('SELECT 1 as test');
release();
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
pool: getPoolStatus(),
dbTest: result[0]
});
} catch (error) {
res.status(500).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString(),
pool: getPoolStatus()
});
}
});
// Helper functions
function processShippingData(shippingResult, totalShipped) {
const countries = {};
const states = {};
const methods = {};
shippingResult.forEach(row => {
// Countries
if (row.ship_country) {
countries[row.ship_country] = (countries[row.ship_country] || 0) + row.count;
}
// States
if (row.ship_state) {
states[row.ship_state] = (states[row.ship_state] || 0) + row.count;
}
// Methods
if (row.ship_method_selected) {
methods[row.ship_method_selected] = (methods[row.ship_method_selected] || 0) + row.count;
}
});
return {
locations: {
total: Object.keys(states).length, // Count of unique states/regions shipped to
byCountry: Object.entries(countries)
.map(([country, count]) => ({
country,
count,
percentage: (count / totalShipped) * 100
}))
.sort((a, b) => b.count - a.count),
byState: Object.entries(states)
.map(([state, count]) => ({
state,
count,
percentage: (count / totalShipped) * 100
}))
.sort((a, b) => b.count - a.count)
},
methods: Object.entries(methods)
.map(([name, value]) => ({ name, value }))
.sort((a, b) => b.value - a.value)
};
}
function calculatePeriodProgress(timeRange) {
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
return 100;
}
const now = DateTime.now().setZone(TIMEZONE);
let range;
try {
range = timeHelpers.getRangeForTimeRange(timeRange, now);
} catch (error) {
console.error(`[STATS] Failed to derive range for ${timeRange}:`, error);
return 100;
}
if (!range?.start || !range?.end) {
return 100;
}
const total = range.end.toMillis() - range.start.toMillis();
if (total <= 0) {
return 100;
}
const elapsed = Math.min(
Math.max(now.toMillis() - range.start.toMillis(), 0),
total
);
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
// Optionally join to _order to exclude Cherry Box orders
if (excludeCherryBox) {
return `
SELECT
COALESCE(SUM(r.sale_amount), 0) as grossSales,
COALESCE(SUM(r.refund_amount), 0) as refunds,
COALESCE(SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount), 0) as shippingFees,
COALESCE(SUM(r.tax_collected_amount), 0) as taxCollected,
COALESCE(SUM(r.discount_total_amount), 0) as discounts,
COALESCE(SUM(r.cogs_amount), 0) as cogs
FROM report_sales_data r
JOIN _order o ON r.order_id = o.order_id
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
AND r.action IN (1, 2, 3)
AND ${getCherryBoxClauseAliased('o', true)}
`;
}
return `
SELECT
COALESCE(SUM(sale_amount), 0) as grossSales,
COALESCE(SUM(refund_amount), 0) as refunds,
COALESCE(SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount), 0) as shippingFees,
COALESCE(SUM(tax_collected_amount), 0) as taxCollected,
COALESCE(SUM(discount_total_amount), 0) as discounts,
COALESCE(SUM(cogs_amount), 0) as cogs
FROM report_sales_data
WHERE ${whereClause}
AND action IN (1, 2, 3)
`;
}
function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
const businessDayOffset = BUSINESS_DAY_START_HOUR;
// Optionally join to _order to exclude Cherry Box orders
if (excludeCherryBox) {
return `
SELECT
DATE_FORMAT(
DATE_SUB(r.date_change, INTERVAL ${businessDayOffset} HOUR),
'%Y-%m-%d'
) as businessDate,
SUM(r.sale_amount) as grossSales,
SUM(r.refund_amount) as refunds,
SUM(r.shipping_collected_amount + r.small_order_fee_amount + r.rush_fee_amount) as shippingFees,
SUM(r.tax_collected_amount) as taxCollected,
SUM(r.discount_total_amount) as discounts,
SUM(r.cogs_amount) as cogs
FROM report_sales_data r
JOIN _order o ON r.order_id = o.order_id
WHERE ${whereClause.replace(/date_change/g, 'r.date_change')}
AND r.action IN (1, 2, 3)
AND ${getCherryBoxClauseAliased('o', true)}
GROUP BY businessDate
ORDER BY businessDate ASC
`;
}
return `
SELECT
DATE_FORMAT(
DATE_SUB(date_change, INTERVAL ${businessDayOffset} HOUR),
'%Y-%m-%d'
) as businessDate,
SUM(sale_amount) as grossSales,
SUM(refund_amount) as refunds,
SUM(shipping_collected_amount + small_order_fee_amount + rush_fee_amount) as shippingFees,
SUM(tax_collected_amount) as taxCollected,
SUM(discount_total_amount) as discounts,
SUM(cogs_amount) as cogs
FROM report_sales_data
WHERE ${whereClause}
AND action IN (1, 2, 3)
GROUP BY businessDate
ORDER BY businessDate ASC
`;
}
function normalizeFinancialTotals(row = {}) {
const grossSales = parseFloat(row.grossSales || 0);
const refunds = parseFloat(row.refunds || 0);
const shippingFees = parseFloat(row.shippingFees || 0);
const taxCollected = parseFloat(row.taxCollected || 0);
const discounts = parseFloat(row.discounts || 0);
const cogs = parseFloat(row.cogs || 0);
const productNet = grossSales - refunds - discounts;
const income = productNet + shippingFees;
const profit = income - cogs;
const margin = income !== 0 ? (profit / income) * 100 : 0;
return {
grossSales,
refunds,
shippingFees,
taxCollected,
discounts,
cogs,
income,
profit,
margin,
};
}
function normalizeFinancialTrendRow(row = {}) {
const grossSales = parseFloat(row.grossSales || 0);
const refunds = parseFloat(row.refunds || 0);
const shippingFees = parseFloat(row.shippingFees || 0);
const taxCollected = parseFloat(row.taxCollected || 0);
const discounts = parseFloat(row.discounts || 0);
const cogs = parseFloat(row.cogs || 0);
const productNet = grossSales - refunds - discounts;
const income = productNet + shippingFees;
const profit = income - cogs;
const margin = income !== 0 ? (profit / income) * 100 : 0;
let timestamp = null;
let dateValue = row.businessDate || row.date || null;
const resolveBusinessDayStart = (value) => {
if (!value) {
return null;
}
let dt;
if (value instanceof Date) {
dt = DateTime.fromJSDate(value, { zone: TIMEZONE });
} else if (typeof value === 'string') {
dt = DateTime.fromISO(value, { zone: TIMEZONE });
if (!dt.isValid) {
dt = DateTime.fromSQL(value, { zone: TIMEZONE });
}
}
if (!dt || !dt.isValid) {
return null;
}
const hour = BUSINESS_DAY_START_HOUR;
return dt.set({
hour,
minute: 0,
second: 0,
millisecond: 0,
});
};
const businessDayStart = resolveBusinessDayStart(dateValue);
if (businessDayStart) {
timestamp = businessDayStart.toUTC().toISO();
dateValue = businessDayStart.toISO();
} else if (row.date instanceof Date) {
timestamp = new Date(row.date.getTime()).toISOString();
} else if (typeof row.date === 'string') {
timestamp = new Date(`${row.date}T00:00:00Z`).toISOString();
}
return {
date: dateValue,
grossSales,
refunds,
shippingFees,
taxCollected,
discounts,
cogs,
income,
profit,
margin,
timestamp,
};
}
function calculateComparison(currentValue, previousValue) {
if (typeof previousValue !== 'number') {
return { absolute: null, percentage: null };
}
const absolute = typeof currentValue === 'number' ? currentValue - previousValue : null;
const percentage =
absolute !== null && previousValue !== 0
? (absolute / Math.abs(previousValue)) * 100
: null;
return { absolute, percentage };
}
function getPreviousPeriodRange(timeRange, startDate, endDate) {
if (timeRange && timeRange !== 'custom') {
const prevTimeRange = getPreviousTimeRange(timeRange);
if (!prevTimeRange || prevTimeRange === timeRange) {
return null;
}
return getTimeRangeConditions(prevTimeRange);
}
const hasCustomDates = (timeRange === 'custom' || !timeRange) && startDate && endDate;
if (!hasCustomDates) {
return null;
}
const start = new Date(startDate);
const end = new Date(endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
return null;
}
const duration = end.getTime() - start.getTime();
if (!Number.isFinite(duration) || duration <= 0) {
return null;
}
const prevEnd = new Date(start.getTime() - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
return getTimeRangeConditions('custom', prevStart.toISOString(), prevEnd.toISOString());
}
async function getPreviousPeriodData(connection, timeRange, startDate, endDate, excludeCherryBox = false) {
// Calculate previous period dates
let prevWhereClause, prevParams;
if (timeRange && timeRange !== 'custom') {
const prevTimeRange = getPreviousTimeRange(timeRange);
const result = getTimeRangeConditions(prevTimeRange);
prevWhereClause = result.whereClause;
prevParams = result.params;
} else {
// Custom date range - go back by the same duration
const start = new Date(startDate);
const end = new Date(endDate);
const duration = end.getTime() - start.getTime();
const prevEnd = new Date(start.getTime() - 1);
const prevStart = new Date(prevEnd.getTime() - duration);
prevWhereClause = 'date_placed >= ? AND date_placed <= ?';
prevParams = [prevStart.toISOString(), prevEnd.toISOString()];
}
// Previous period query (optionally excludes Cherry Box orders)
const prevQuery = `
SELECT
COUNT(*) as orderCount,
SUM(summary_total) as revenue,
AVG(summary_total) as averageOrderValue
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCherryBox)} AND ${prevWhereClause}
`;
const [prevResult] = await connection.execute(prevQuery, prevParams);
const prev = prevResult[0] || { orderCount: 0, revenue: 0, averageOrderValue: 0 };
return {
orderCount: parseInt(prev.orderCount || 0),
revenue: parseFloat(prev.revenue || 0),
averageOrderValue: parseFloat(prev.averageOrderValue || 0)
};
}
function getPreviousTimeRange(timeRange) {
const map = {
today: 'yesterday',
thisWeek: 'lastWeek',
thisMonth: 'lastMonth',
last7days: 'previous7days',
last30days: 'previous30days',
last90days: 'previous90days',
yesterday: 'twoDaysAgo'
};
return map[timeRange] || timeRange;
}
/**
* Get hourly revenue distribution pattern from last 8 weeks (same day of week)
* Returns array of 24 objects with hour and avgShare (0-1 representing % of daily revenue)
* Optimized: Uses JOIN instead of correlated subquery for O(n) instead of O(n²)
*/
async function getHourlyRevenuePattern(connection, excludeCherryBox = false) {
const now = DateTime.now().setZone(TIMEZONE);
const dayOfWeek = now.weekday; // 1=Monday, 7=Sunday (Luxon)
const mysqlDayOfWeek = dayOfWeek === 7 ? 1 : dayOfWeek + 1;
// Step 1: Get daily totals and hourly breakdowns in one efficient query
const query = `
SELECT
hourly.hour_of_day,
AVG(hourly.hour_revenue / daily.daily_revenue) as avgShare
FROM (
SELECT
DATE(date_placed) as order_date,
HOUR(date_placed) as hour_of_day,
SUM(summary_total) as hour_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ?
GROUP BY DATE(date_placed), HOUR(date_placed)
) hourly
JOIN (
SELECT
DATE(date_placed) as order_date,
SUM(summary_total) as daily_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ?
GROUP BY DATE(date_placed)
HAVING daily_revenue > 0
) daily ON hourly.order_date = daily.order_date
GROUP BY hourly.hour_of_day
ORDER BY hourly.hour_of_day
`;
const [result] = await connection.execute(query, [mysqlDayOfWeek, mysqlDayOfWeek]);
// Convert to a full 24-hour array, filling gaps with 0
const hourlyPattern = Array(24).fill(0).map((_, i) => ({ hour: i, avgShare: 0 }));
result.forEach(row => {
hourlyPattern[row.hour_of_day] = {
hour: row.hour_of_day,
avgShare: parseFloat(row.avgShare) || 0
};
});
// Normalize so shares sum to 1.0
const totalShare = hourlyPattern.reduce((sum, h) => sum + h.avgShare, 0);
if (totalShare > 0) {
hourlyPattern.forEach(h => h.avgShare = h.avgShare / totalShare);
}
return hourlyPattern;
}
/**
* Get day-of-week revenue distribution pattern from last 8 weeks
* Returns array of 7 objects with dayOfWeek (1-7, Sunday=1) and avgShare
* Optimized: Uses JOIN instead of correlated subquery
*/
async function getDayOfWeekRevenuePattern(connection, excludeCherryBox = false) {
const query = `
SELECT
daily.day_of_week,
AVG(daily.day_revenue / weekly.weekly_revenue) as avgShare
FROM (
SELECT
YEARWEEK(date_placed, 0) as year_week,
DAYOFWEEK(date_placed) as day_of_week,
SUM(summary_total) as day_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
GROUP BY YEARWEEK(date_placed, 0), DAYOFWEEK(date_placed)
) daily
JOIN (
SELECT
YEARWEEK(date_placed, 0) as year_week,
SUM(summary_total) as weekly_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
GROUP BY YEARWEEK(date_placed, 0)
HAVING weekly_revenue > 0
) weekly ON daily.year_week = weekly.year_week
GROUP BY daily.day_of_week
ORDER BY daily.day_of_week
`;
const [result] = await connection.execute(query);
// Convert to array indexed by MySQL day of week (1=Sunday, 2=Monday, etc.)
const weekPattern = Array(8).fill(0).map((_, i) => ({ dayOfWeek: i, avgShare: 0 }));
result.forEach(row => {
weekPattern[row.day_of_week] = {
dayOfWeek: row.day_of_week,
avgShare: parseFloat(row.avgShare) || 0
};
});
// Normalize (indices 1-7 are used, 0 is unused)
const totalShare = weekPattern.slice(1).reduce((sum, d) => sum + d.avgShare, 0);
if (totalShare > 0) {
weekPattern.forEach(d => { if (d.dayOfWeek > 0) d.avgShare = d.avgShare / totalShare; });
}
return weekPattern;
}
/**
* Get average daily revenue for projection (last 30 days, excluding today)
* Also gets same-day-of-week stats for more accurate confidence calculation
*/
async function getAverageDailyRevenue(connection, excludeCherryBox = false) {
const now = DateTime.now().setZone(TIMEZONE);
const mysqlDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1;
// Get both overall 30-day stats AND same-day-of-week stats
const query = `
SELECT
AVG(daily_revenue) as avgDailyRevenue,
STDDEV(daily_revenue) as stdDev,
COUNT(*) as dayCount,
(
SELECT AVG(day_rev) FROM (
SELECT SUM(summary_total) as day_rev
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
GROUP BY DATE(date_placed)
) same_day
) as sameDayAvg,
(
SELECT STDDEV(day_rev) FROM (
SELECT SUM(summary_total) as day_rev
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
GROUP BY DATE(date_placed)
) same_day_std
) as sameDayStdDev,
(
SELECT COUNT(*) FROM (
SELECT DATE(date_placed) as d
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 8 WEEK)
AND date_placed < DATE(NOW())
AND DAYOFWEEK(date_placed) = ${mysqlDayOfWeek}
GROUP BY DATE(date_placed)
) same_day_count
) as sameDayCount
FROM (
SELECT
DATE(date_placed) as order_date,
SUM(summary_total) as daily_revenue
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCherryBox)}
AND date_placed >= DATE_SUB(NOW(), INTERVAL 30 DAY)
AND date_placed < DATE(NOW())
GROUP BY DATE(date_placed)
) daily_totals
`;
const [result] = await connection.execute(query);
const row = result[0] || {};
return {
avgDailyRevenue: parseFloat(row.avgDailyRevenue) || 0,
stdDev: parseFloat(row.stdDev) || 0,
dayCount: parseInt(row.dayCount) || 0,
sameDayAvg: parseFloat(row.sameDayAvg) || 0,
sameDayStdDev: parseFloat(row.sameDayStdDev) || 0,
sameDayCount: parseInt(row.sameDayCount) || 0
};
}
/**
* Calculate meaningful confidence score based on multiple factors
* Returns score between 0-1 and breakdown of contributing factors
*/
function calculateConfidence({
expectedProgress,
currentRevenue,
patternProjection,
historicalDailyAvg,
sameDayStdDev,
sameDayCount,
stdDev,
dayCount
}) {
const factors = {};
// Factor 1: Time Progress (0-0.3)
// More time elapsed = more data = higher confidence
// Scales from 0 at 0% to 0.3 at 100%
factors.timeProgress = Math.min(0.3, expectedProgress * 0.35);
// Factor 2: Historical Predictability via Coefficient of Variation (0-0.35)
// CV = stdDev / mean - lower is more predictable
// Use same-day-of-week stats if available (more relevant)
const relevantStdDev = sameDayStdDev || stdDev || 0;
const relevantAvg = historicalDailyAvg || 1;
const cv = relevantStdDev / relevantAvg;
// CV of 0.1 (10% variation) = very predictable = full points
// CV of 0.5 (50% variation) = unpredictable = minimal points
// Scale: CV 0.1 -> 0.35, CV 0.3 -> 0.15, CV 0.5+ -> 0.05
if (cv <= 0.1) {
factors.predictability = 0.35;
} else if (cv <= 0.5) {
factors.predictability = Math.max(0.05, 0.35 - (cv - 0.1) * 0.75);
} else {
factors.predictability = 0.05;
}
// Factor 3: Tracking Accuracy (0-0.25)
// How well is today tracking the expected pattern?
// If we're at 40% progress with 38-42% of expected revenue, that's good
if (expectedProgress > 0.05 && historicalDailyAvg > 0) {
const expectedRevenueSoFar = historicalDailyAvg * expectedProgress;
const trackingRatio = currentRevenue / expectedRevenueSoFar;
// Perfect tracking (ratio = 1.0) = full points
// 20% off (ratio 0.8 or 1.2) = partial points
// 50%+ off = minimal points
const deviation = Math.abs(1 - trackingRatio);
if (deviation <= 0.1) {
factors.tracking = 0.25;
} else if (deviation <= 0.3) {
factors.tracking = 0.25 - (deviation - 0.1) * 0.5;
} else if (deviation <= 0.5) {
factors.tracking = 0.15 - (deviation - 0.3) * 0.4;
} else {
factors.tracking = 0.05;
}
} else {
// Not enough progress to judge tracking
factors.tracking = 0.1;
}
// Factor 4: Data Quality (0-0.1)
// More historical data points = more reliable pattern
const dataPoints = sameDayCount || Math.floor(dayCount / 7) || 0;
// 8 weeks of same-day data = full points, less = proportionally less
factors.dataQuality = Math.min(0.1, (dataPoints / 8) * 0.1);
// Calculate total confidence score
const score = Math.min(0.95, Math.max(0.1,
factors.timeProgress +
factors.predictability +
factors.tracking +
factors.dataQuality
));
return { score, factors };
}
/**
* Calculate pattern-based projection for different time ranges
*/
function calculateSmartProjection(
timeRange,
currentRevenue,
currentOrders,
periodProgress,
hourlyPattern,
dayOfWeekPattern,
dailyStats,
now
) {
if (periodProgress >= 100) {
return { projectedRevenue: currentRevenue, projectedOrders: currentOrders, confidence: 1.0 };
}
const currentHour = now.hour;
const currentDayOfWeek = now.weekday === 7 ? 1 : now.weekday + 1; // Convert to MySQL day (1=Sunday)
if (timeRange === 'today') {
// Calculate expected progress based on hourly pattern
// Sum up shares for all hours up to and including current hour
let expectedProgress = 0;
for (let h = 0; h <= currentHour; h++) {
expectedProgress += hourlyPattern[h]?.avgShare || 0;
}
// Adjust for partial hour (how far through current hour we are)
const minuteProgress = now.minute / 60;
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
expectedProgress = expectedProgress - currentHourShare + (currentHourShare * minuteProgress);
// Avoid division by zero and handle edge cases
if (expectedProgress <= 0.01) {
// Very early in day, use linear projection with low confidence
const linearProjection = currentRevenue / Math.max(periodProgress / 100, 0.01);
return {
projectedRevenue: linearProjection,
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
confidence: 0.1,
method: 'linear_fallback'
};
}
const patternProjection = currentRevenue / expectedProgress;
// Blend with historical average for stability early in day
// Use same-day-of-week average if available, otherwise fall back to overall average
const historicalDailyAvg = dailyStats.sameDayAvg || dailyStats.avgDailyRevenue || patternProjection;
const actualWeight = Math.pow(expectedProgress, 0.8); // More weight to actual as day progresses
const projectedRevenue = (patternProjection * actualWeight) + (historicalDailyAvg * (1 - actualWeight));
// Calculate meaningful confidence based on multiple factors
const confidence = calculateConfidence({
expectedProgress,
currentRevenue,
patternProjection,
historicalDailyAvg,
sameDayStdDev: dailyStats.sameDayStdDev,
sameDayCount: dailyStats.sameDayCount,
stdDev: dailyStats.stdDev,
dayCount: dailyStats.dayCount
});
return {
projectedRevenue,
projectedOrders: Math.round(currentOrders / expectedProgress),
confidence: confidence.score,
confidenceFactors: confidence.factors,
method: 'hourly_pattern',
debug: { expectedProgress, actualWeight, patternProjection, historicalDailyAvg }
};
}
if (timeRange === 'thisWeek') {
// Calculate revenue expected so far this week based on day-of-week pattern
// And project remaining days
// Days completed so far (Sunday = day 1 in MySQL)
// If today is Tuesday (MySQL day 3), completed days are Sunday(1) and Monday(2)
let expectedProgressSoFar = 0;
for (let d = 1; d < currentDayOfWeek; d++) {
expectedProgressSoFar += dayOfWeekPattern[d]?.avgShare || 0;
}
// Add partial progress through today using hourly pattern
let todayExpectedProgress = 0;
for (let h = 0; h <= currentHour; h++) {
todayExpectedProgress += hourlyPattern[h]?.avgShare || 0;
}
const minuteProgress = now.minute / 60;
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress);
// Add today's partial contribution
const todayFullShare = dayOfWeekPattern[currentDayOfWeek]?.avgShare || (1/7);
expectedProgressSoFar += todayFullShare * todayExpectedProgress;
// Avoid division by zero
if (expectedProgressSoFar <= 0.01) {
return {
projectedRevenue: currentRevenue / Math.max(periodProgress / 100, 0.01),
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
confidence: 0.1,
method: 'linear_fallback'
};
}
const projectedWeekRevenue = currentRevenue / expectedProgressSoFar;
const projectedWeekOrders = Math.round(currentOrders / expectedProgressSoFar);
// Calculate meaningful confidence
const historicalWeeklyAvg = dailyStats.avgDailyRevenue * 7;
const confidence = calculateConfidence({
expectedProgress: expectedProgressSoFar,
currentRevenue,
patternProjection: projectedWeekRevenue,
historicalDailyAvg: historicalWeeklyAvg,
sameDayStdDev: dailyStats.stdDev * Math.sqrt(7), // Approximate weekly stdDev
sameDayCount: Math.floor(dailyStats.dayCount / 7),
stdDev: dailyStats.stdDev * Math.sqrt(7),
dayCount: dailyStats.dayCount
});
return {
projectedRevenue: projectedWeekRevenue,
projectedOrders: projectedWeekOrders,
confidence: confidence.score,
confidenceFactors: confidence.factors,
method: 'weekly_pattern',
debug: { expectedProgressSoFar, currentDayOfWeek, todayExpectedProgress }
};
}
if (timeRange === 'thisMonth') {
// For month projection, use days elapsed and average daily revenue
const currentDay = now.day;
const daysInMonth = now.daysInMonth;
// Calculate average daily revenue so far this month
const daysElapsed = currentDay - 1; // Full days completed
// Add partial progress through today
let todayExpectedProgress = 0;
for (let h = 0; h <= currentHour; h++) {
todayExpectedProgress += hourlyPattern[h]?.avgShare || 0;
}
const minuteProgress = now.minute / 60;
const currentHourShare = hourlyPattern[currentHour]?.avgShare || 0;
todayExpectedProgress = todayExpectedProgress - currentHourShare + (currentHourShare * minuteProgress);
const effectiveDaysElapsed = daysElapsed + todayExpectedProgress;
if (effectiveDaysElapsed <= 0.1) {
// Very early in month, use historical average
const projectedRevenue = dailyStats.avgDailyRevenue * daysInMonth;
return {
projectedRevenue,
projectedOrders: Math.round(currentOrders / Math.max(periodProgress / 100, 0.01)),
confidence: 0.15,
method: 'historical_monthly'
};
}
// Calculate implied daily rate from current data
const impliedDailyRate = currentRevenue / effectiveDaysElapsed;
// Blend with historical average (more weight to actual data as month progresses)
const actualWeight = Math.min(0.9, effectiveDaysElapsed / 10); // Full weight after ~10 days
const blendedDailyRate = (impliedDailyRate * actualWeight) + (dailyStats.avgDailyRevenue * (1 - actualWeight));
const projectedMonthRevenue = blendedDailyRate * daysInMonth;
const projectedMonthOrders = Math.round((currentOrders / effectiveDaysElapsed) * daysInMonth);
// Calculate meaningful confidence
const historicalMonthlyAvg = dailyStats.avgDailyRevenue * daysInMonth;
const confidence = calculateConfidence({
expectedProgress: effectiveDaysElapsed / daysInMonth,
currentRevenue,
patternProjection: projectedMonthRevenue,
historicalDailyAvg: historicalMonthlyAvg,
sameDayStdDev: dailyStats.stdDev * Math.sqrt(daysInMonth),
sameDayCount: 1, // Only ~1 month of same-month data typically
stdDev: dailyStats.stdDev * Math.sqrt(daysInMonth),
dayCount: dailyStats.dayCount
});
return {
projectedRevenue: projectedMonthRevenue,
projectedOrders: projectedMonthOrders,
confidence: confidence.score,
confidenceFactors: confidence.factors,
method: 'monthly_blend',
debug: { effectiveDaysElapsed, daysInMonth, impliedDailyRate, blendedDailyRate }
};
}
// Fallback for any other case
const linearProjection = currentRevenue / (periodProgress / 100);
return {
projectedRevenue: linearProjection,
projectedOrders: Math.round(currentOrders / (periodProgress / 100)),
confidence: Math.min(0.95, Math.max(0.1, periodProgress / 100)),
method: 'linear_fallback'
};
}
// Health check endpoint
router.get('/health', async (req, res) => {
try {
const poolStatus = getPoolStatus();
// Test database connectivity
const { connection, release } = await getDbConnection();
await connection.execute('SELECT 1 as test');
release();
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
pool: poolStatus,
database: 'connected'
});
} catch (error) {
console.error('Health check failed:', error);
res.status(500).json({
status: 'unhealthy',
timestamp: new Date().toISOString(),
error: error.message,
pool: getPoolStatus()
});
}
});
// Debug endpoint for pool status
router.get('/debug/pool', (req, res) => {
res.json({
timestamp: new Date().toISOString(),
pool: getPoolStatus()
});
});
module.exports = router;