Time unification

This commit is contained in:
2026-06-17 15:06:38 -04:00
parent 069a44bd54
commit 54a2460eac
67 changed files with 1442 additions and 1329 deletions
@@ -5,8 +5,12 @@ import {
getTimeRangeConditions,
formatBusinessDate,
getBusinessDayBounds,
customRange,
previousRange,
parseMySql,
toMySqlBound,
_internal as timeHelpers,
} from '../utils/timeUtils.js';
} from '../../../shared/business-time/index.js';
const router = express.Router();
@@ -83,7 +87,7 @@ router.get('/stats', async (req, res) => {
FROM _order
WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_cancelled')}
AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
`;
const [cancelledResult] = await connection.execute(cancelledQuery, params);
@@ -96,7 +100,7 @@ router.get('/stats', async (req, res) => {
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')}
WHERE payment_amount < 0 AND o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
`;
const [refundStats] = await connection.execute(refundsQuery, params);
@@ -108,7 +112,7 @@ router.get('/stats', async (req, res) => {
FROM _order
WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_shipped')}
AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
`;
const [shippedResult] = await connection.execute(shippedQuery, params);
@@ -129,29 +133,29 @@ router.get('/stats', async (req, res) => {
const [bestDayResult] = await connection.execute(bestDayQuery, params);
// Peak hour query - uses selected time range for the card value
// Peak hour query - uses selected time range for the card value.
// date_placed stores Central wall-clock; the office reads the axis in
// Eastern, and ET = CT + 1h year-round, so shift before taking HOUR().
let peakHour = null;
if (['today', 'yesterday'].includes(timeRange)) {
const peakHourQuery = `
SELECT
HOUR(date_placed) as hour,
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
COUNT(*) as count
FROM _order
WHERE order_status > 15 AND ${getCherryBoxClause(excludeCB)} AND ${whereClause}
GROUP BY HOUR(date_placed)
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
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);
const hour = parseInt(peakHourResult[0].hour);
peakHour = {
hour,
count: parseInt(peakHourResult[0].count),
displayHour: date.toLocaleString("en-US", { hour: "numeric", hour12: true })
displayHour: DateTime.fromObject({ hour }).toFormat('h a')
};
}
}
@@ -160,23 +164,27 @@ router.get('/stats', async (req, res) => {
// 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
// Hourly counts in EASTERN display hours (column stores Central
// wall-clock; ET = CT + 1h year-round). currentHour comes from the same
// expression so the rolling window stays aligned.
const hourlyQuery = `
SELECT
HOUR(date_placed) as hour,
HOUR(ADDTIME(date_placed, '01:00:00')) as hour,
COUNT(*) as count,
HOUR(NOW()) as currentHour
HOUR(ADDTIME(NOW(), '01:00:00')) as currentHour
FROM _order
WHERE order_status > 15
AND ${getCherryBoxClause(excludeCB)}
AND date_placed >= NOW() - INTERVAL 24 HOUR
GROUP BY HOUR(date_placed)
GROUP BY HOUR(ADDTIME(date_placed, '01:00:00'))
`;
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();
// Current hour in Eastern (fallback computes it directly)
const currentHour = hourlyResult.length > 0
? parseInt(hourlyResult[0].currentHour)
: DateTime.now().setZone(TIMEZONE).hour;
// Build map of hour -> count
const hourCounts = {};
@@ -208,7 +216,7 @@ router.get('/stats', async (req, res) => {
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')}
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
GROUP BY pc.cat_id, pc.name
HAVING revenue > 0
ORDER BY revenue DESC
@@ -233,7 +241,7 @@ router.get('/stats', async (req, res) => {
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 ${whereClause.replace(/date_placed/g, 'o.date_placed')}
AND pc.type IN (10, 20, 11, 21, 12, 13)
GROUP BY pc.cat_id, pc.name
HAVING revenue > 0
@@ -253,7 +261,7 @@ router.get('/stats', async (req, res) => {
FROM _order
WHERE order_status IN (92, 95, 100)
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_shipped')}
AND ${whereClause.replace(/date_placed/g, 'date_shipped')}
GROUP BY ship_country, ship_state, ship_method_selected
`;
@@ -425,7 +433,7 @@ router.get('/stats/details', async (req, res) => {
WHERE op.payment_amount < 0
AND o.order_status > 15
AND ${getCherryBoxClauseAliased('o', excludeCB)}
AND ${whereClause.replace('date_placed', 'op.payment_date')}
AND ${whereClause.replace(/date_placed/g, 'op.payment_date')}
GROUP BY DATE(op.payment_date)
ORDER BY DATE(op.payment_date)
`;
@@ -457,7 +465,7 @@ router.get('/stats/details', async (req, res) => {
FROM _order
WHERE order_status = 15
AND ${getCherryBoxClause(excludeCB)}
AND ${whereClause.replace('date_placed', 'date_cancelled')}
AND ${whereClause.replace(/date_placed/g, 'date_cancelled')}
GROUP BY DATE(date_cancelled)
ORDER BY DATE(date_cancelled)
`;
@@ -549,16 +557,10 @@ router.get('/stats/details', async (req, res) => {
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()];
// Custom date range - go back by the same duration (business-day aware)
const prev = previousRange(customRange(startDate, endDate));
prevWhereClause = 'date_placed >= ? AND date_placed < ?';
prevParams = [toMySqlBound(prev.start), toMySqlBound(prev.end)];
}
// Get previous period daily data (optionally excludes Cherry Box orders)
@@ -572,14 +574,14 @@ router.get('/stats/details', async (req, res) => {
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
// (dateStrings: true — DATE columns arrive as 'YYYY-MM-DD' strings)
const prevMap = new Map();
prevResults.forEach(prev => {
const key = new Date(prev.date).toISOString().split('T')[0];
prevMap.set(key, prev);
prevMap.set(String(prev.date), prev);
});
// For period-to-period comparison, we need to map days by relative position
@@ -636,8 +638,8 @@ router.get('/financials', async (req, res) => {
const formatDebugBound = (value) => {
if (!value) return 'n/a';
const parsed = DateTime.fromSQL(value, { zone: 'UTC-05:00' });
if (!parsed.isValid) {
const parsed = parseMySql(value);
if (!parsed) {
return `invalid(${value})`;
}
return parsed.setZone(TIMEZONE).toISO();
@@ -771,7 +773,7 @@ router.get('/products', async (req, res) => {
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')}
WHERE o.order_status > 15 AND ${getCherryBoxClauseAliased('o', excludeCB)} AND ${whereClause.replace(/date_placed/g, 'o.date_placed')}
GROUP BY p.pid, p.description
ORDER BY totalRevenue DESC
LIMIT 500
@@ -1029,15 +1031,13 @@ function buildFinancialTotalsQuery(whereClause, excludeCherryBox = false) {
}
function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
const businessDayOffset = BUSINESS_DAY_START_HOUR;
// date_change stores Central wall-clock literals; business date = the
// Central calendar date, so DATE_FORMAT(col) needs no hour shift.
// 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,
SELECT
DATE_FORMAT(r.date_change, '%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,
@@ -1054,11 +1054,8 @@ function buildFinancialTrendQuery(whereClause, excludeCherryBox = false) {
`;
}
return `
SELECT
DATE_FORMAT(
DATE_SUB(date_change, INTERVAL ${businessDayOffset} HOUR),
'%Y-%m-%d'
) as businessDate,
SELECT
DATE_FORMAT(date_change, '%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,
@@ -1193,22 +1190,13 @@ function getPreviousPeriodRange(timeRange, startDate, endDate) {
return null;
}
const start = new Date(startDate);
const end = new Date(endDate);
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
try {
const prev = previousRange(customRange(startDate, endDate));
if (!prev) return null;
return getTimeRangeConditions('custom', prev.start.toISO(), prev.end.toISO());
} catch {
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) {
@@ -1221,18 +1209,12 @@ async function getPreviousPeriodData(connection, timeRange, startDate, endDate,
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()];
// Custom date range - go back by the same duration (business-day aware)
const prev = previousRange(customRange(startDate, endDate));
prevWhereClause = 'date_placed >= ? AND date_placed < ?';
prevParams = [toMySqlBound(prev.start), toMySqlBound(prev.end)];
}
// Previous period query (optionally excludes Cherry Box orders)
const prevQuery = `
SELECT