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;