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
@@ -211,7 +211,12 @@ async function setupSshTunnel() {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
timezone: 'Z'
// DATETIME columns in this DB store Central wall-clock literals. Return
// them as literal strings so consumers parse them explicitly with
// parseMySql() (zone America/Chicago) from shared/business-time — never
// let the driver guess a zone (the old `timezone: 'Z'` mangled every
// value by labeling Central wall-clock as UTC).
dateStrings: true
};
return new Promise((resolve, reject) => {
@@ -230,6 +230,8 @@ router.get('/:cid/orders', async (req, res) => {
try {
// MySQL-safe equivalent of the Laravel query in the freescout module.
// Active = placed OR shipped within the last 3 months.
// NOW() here is Central (server TZ) — intended: this is a rough
// trailing window, not a business-day boundary.
const [ordersRaw] = await connection.execute(
`SELECT order_id, order_status, order_type, summary_total,
date_placed, ship_method_type, ship_method_tracking,
@@ -1,6 +1,7 @@
import express from 'express';
import { DateTime } from 'luxon';
import { getDbConnection } from '../db/connection.js';
import { BUSINESS_TZ, businessNow, toMySqlBound } from '../../../shared/business-time/index.js';
const router = express.Router();
@@ -55,20 +56,20 @@ const DEFAULTS = {
pointDollarValue: DEFAULT_POINT_DOLLAR_VALUE,
};
// Parse date inputs in BUSINESS time (date-only strings = business dates;
// zoned instants keep their instant). Never zoneless — that was Berlin.
function parseDate(value, fallback) {
if (!value) {
return fallback;
}
const parsed = DateTime.fromISO(value);
const parsed = DateTime.fromISO(value, { zone: BUSINESS_TZ });
if (!parsed.isValid) {
return fallback;
}
return parsed;
}
function formatDateForSql(dt) {
return dt.toFormat('yyyy-LL-dd HH:mm:ss');
}
const formatDateForSql = (dt) => toMySqlBound(dt);
router.get('/promos', async (req, res) => {
let connection;
@@ -78,11 +79,12 @@ router.get('/promos', async (req, res) => {
const releaseConnection = release;
const { startDate, endDate } = req.query || {};
const now = DateTime.now().endOf('day');
// Half-open business-day range [start, dayAfterEnd) in Chicago time.
const now = businessNow();
const defaultStart = now.minus({ years: 3 }).startOf('day');
const parsedStart = startDate ? parseDate(startDate, defaultStart).startOf('day') : defaultStart;
const parsedEnd = endDate ? parseDate(endDate, now).endOf('day') : now;
const parsedEnd = (endDate ? parseDate(endDate, now) : now).startOf('day').plus({ days: 1 });
const rangeStart = parsedStart <= parsedEnd ? parsedStart : parsedEnd;
const rangeEnd = parsedEnd >= parsedStart ? parsedEnd : parsedStart;
@@ -110,7 +112,7 @@ router.get('/promos', async (req, res) => {
) u ON u.discount_code = p.promo_id
WHERE p.date_start IS NOT NULL
AND p.date_end IS NOT NULL
AND NOT (p.date_end < ? OR p.date_start > ?)
AND NOT (p.date_end < ? OR p.date_start >= ?)
AND p.store = 1
AND p.date_start >= '2010-01-01'
ORDER BY p.promo_id DESC
@@ -343,10 +345,11 @@ router.post('/simulate', async (req, res) => {
pointsConfig = {}
} = req.body || {};
const endDefault = DateTime.now();
// Half-open business-day bounds [startOfDay, dayAfterEnd) in Chicago time.
const endDefault = businessNow();
const startDefault = endDefault.minus({ months: 6 });
const startDt = parseDate(dateRange.start, startDefault).startOf('day');
const endDt = parseDate(dateRange.end, endDefault).endOf('day');
const endDt = parseDate(dateRange.end, endDefault).startOf('day').plus({ days: 1 });
const shipCountry = filters.shipCountry || 'US';
const promoIds = Array.from(
@@ -463,7 +466,7 @@ router.post('/simulate', async (req, res) => {
AND o.order_status >= 20
AND o.ship_method_selected <> 'holdit'
AND o.ship_country = ?
AND o.date_placed BETWEEN ? AND ?
AND o.date_placed >= ? AND o.date_placed < ?
${promoExistsClause}
`;
@@ -1,12 +1,25 @@
import express from 'express';
import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js';
import { getTimeRangeConditions, _internal as timeHelpers } from '../utils/timeUtils.js';
import {
getTimeRangeConditions,
customRange,
previousRange,
parseMySql,
BUSINESS_TZ,
} from '../../../shared/business-time/index.js';
const router = express.Router();
const TIMEZONE = 'America/New_York';
// timeclock.TimeStamp stores Central wall-clock literals; the driver returns
// them as strings (dateStrings: true). Parse explicitly in business time.
const punchTime = (punch) => {
const dt = parseMySql(punch.TimeStamp);
return dt ? dt.toJSDate() : new Date(NaN);
};
// Punch types from the database
const PUNCH_TYPES = {
OUT: 0,
@@ -40,7 +53,7 @@ function calculateHoursFromPunches(punches) {
byEmployee.forEach((employeePunches, employeeId) => {
// Sort by timestamp
employeePunches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
employeePunches.sort((a, b) => punchTime(a) - punchTime(b));
let hours = 0;
let breakHours = 0;
@@ -48,24 +61,24 @@ function calculateHoursFromPunches(punches) {
let breakStart = null;
employeePunches.forEach(punch => {
const punchTime = new Date(punch.TimeStamp);
const punchAt = punchTime(punch);
switch (punch.PunchType) {
case PUNCH_TYPES.IN:
currentIn = punchTime;
currentIn = punchAt;
break;
case PUNCH_TYPES.OUT:
if (currentIn) {
hours += (punchTime - currentIn) / (1000 * 60 * 60); // Convert ms to hours
hours += (punchAt - currentIn) / (1000 * 60 * 60); // Convert ms to hours
currentIn = null;
}
break;
case PUNCH_TYPES.BREAK_START:
breakStart = punchTime;
breakStart = punchAt;
break;
case PUNCH_TYPES.BREAK_END:
if (breakStart) {
breakHours += (punchTime - breakStart) / (1000 * 60 * 60);
breakHours += (punchAt - breakStart) / (1000 * 60 * 60);
breakStart = null;
}
break;
@@ -315,13 +328,13 @@ router.get('/', async (req, res) => {
const periodDays = Math.max(1, (periodEnd - periodStart) / (1000 * 60 * 60 * 24));
const weeksInPeriod = periodDays / 7;
// Get daily trend data for hours
// Use DATE_FORMAT to get date string in Eastern timezone, avoiding JS timezone conversion issues
// Business day starts at 1 AM, so subtract 1 hour before taking the date
// Get daily trend data for hours.
// TimeStamp stores Central wall-clock literals; business date = the
// Central calendar date, so DATE_FORMAT(col) needs no hour shift.
const trendWhere = whereClause.replace(/date_placed/g, 'tc.TimeStamp');
const trendQuery = `
SELECT
DATE_FORMAT(DATE_SUB(tc.TimeStamp, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
DATE_FORMAT(tc.TimeStamp, '%Y-%m-%d') as date,
tc.EmployeeID,
tc.TimeStamp,
tc.PunchType
@@ -337,18 +350,19 @@ router.get('/', async (req, res) => {
// Get daily picking data for trend
// Ship-together orders: only count main orders (is_sub = 0 or NULL)
// Use DATE_FORMAT for consistent date string format
// createddate is a Central wall-clock literal — DATE_FORMAT(col) is the
// business date with no hour shift.
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = `
SELECT
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked,
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
FROM picking_ticket pt
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
ORDER BY date
`;
@@ -376,13 +390,14 @@ router.get('/', async (req, res) => {
byDate.get(date).push(row);
});
// Generate all dates in the period range for complete trend data
// Generate all business dates in the half-open period [start, end) for
// complete trend data. Business date = Chicago calendar date.
const allDatesInRange = [];
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
const startDt = DateTime.fromJSDate(periodStart).setZone(BUSINESS_TZ).startOf('day');
const endDt = DateTime.fromJSDate(periodEnd).setZone(BUSINESS_TZ);
let currentDt = startDt;
while (currentDt <= endDt) {
while (currentDt < endDt) {
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
currentDt = currentDt.plus({ days: 1 });
}
@@ -393,7 +408,8 @@ router.get('/', async (req, res) => {
const { totalHours: dayHours, employeeHours: dayEmployeeHours } = calculateHoursFromPunches(punches);
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
// Parse date string in Eastern timezone to get proper ISO timestamp
// The string is already the business date; the zone here is only for
// the display timestamp label (office time).
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
return {
@@ -646,22 +662,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());
}
function getPreviousTimeRange(timeRange) {
@@ -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
@@ -1,7 +1,12 @@
import express from 'express';
import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js';
import { getTimeRangeConditions } from '../utils/timeUtils.js';
import {
getTimeRangeConditions,
customRange,
previousRange,
BUSINESS_TZ,
} from '../../../shared/business-time/index.js';
const router = express.Router();
@@ -170,9 +175,10 @@ router.get('/', async (req, res) => {
const ordersPerHour = totalPickingHours > 0 ? totalOrdersPicked / totalPickingHours : 0;
const piecesPerHour = totalPickingHours > 0 ? totalPiecesPicked / totalPickingHours : 0;
// Get daily trend data for picking
// Use DATE_FORMAT to get date string in Eastern timezone
// Business day starts at 1 AM, so subtract 1 hour before taking the date
// Get daily trend data for picking.
// Columns store Central wall-clock literals, and the business date IS
// the Central calendar date — so DATE(col) is the business date with
// no hour shift.
const pickingTrendWhere = whereClause.replace(/date_placed/g, 'pt.createddate');
const pickingTrendQuery = `
SELECT
@@ -181,22 +187,22 @@ router.get('/', async (req, res) => {
pt_agg.piecesPicked
FROM (
SELECT
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
COALESCE(SUM(pt.totalpieces_picked), 0) as piecesPicked
FROM picking_ticket pt
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
) pt_agg
LEFT JOIN (
SELECT
DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
DATE_FORMAT(pt.createddate, '%Y-%m-%d') as date,
COUNT(DISTINCT CASE WHEN ptb.is_sub = 0 OR ptb.is_sub IS NULL THEN ptb.orderid END) as ordersPicked
FROM picking_ticket pt
LEFT JOIN picking_ticket_buckets ptb ON pt.pickingid = ptb.pickingid
WHERE ${pickingTrendWhere}
AND pt.closeddate IS NOT NULL
GROUP BY DATE_FORMAT(DATE_SUB(pt.createddate, INTERVAL 1 HOUR), '%Y-%m-%d')
GROUP BY DATE_FORMAT(pt.createddate, '%Y-%m-%d')
) order_counts ON pt_agg.date = order_counts.date
ORDER BY pt_agg.date
`;
@@ -205,13 +211,13 @@ router.get('/', async (req, res) => {
const shippingTrendWhere = whereClause.replace(/date_placed/g, 'o.date_shipped');
const shippingTrendQuery = `
SELECT
DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d') as date,
DATE_FORMAT(o.date_shipped, '%Y-%m-%d') as date,
COUNT(DISTINCT CASE WHEN o.order_type != 8 OR o.order_type IS NULL THEN o.order_id END) as ordersShipped,
COALESCE(SUM(o.stats_prod_pieces), 0) as piecesShipped
FROM _order o
WHERE ${shippingTrendWhere}
AND o.order_status IN (100, 92)
GROUP BY DATE_FORMAT(DATE_SUB(o.date_shipped, INTERVAL 1 HOUR), '%Y-%m-%d')
GROUP BY DATE_FORMAT(o.date_shipped, '%Y-%m-%d')
ORDER BY date
`;
@@ -239,13 +245,14 @@ router.get('/', async (req, res) => {
});
});
// Generate all dates in the period range for complete trend data
// Generate all business dates in the half-open period [start, end) for
// complete trend data. Business date = Chicago calendar date.
const allDatesInRange = [];
const startDt = DateTime.fromJSDate(periodStart).setZone(TIMEZONE).startOf('day');
const endDt = DateTime.fromJSDate(periodEnd).setZone(TIMEZONE).startOf('day');
const startDt = DateTime.fromJSDate(periodStart).setZone(BUSINESS_TZ).startOf('day');
const endDt = DateTime.fromJSDate(periodEnd).setZone(BUSINESS_TZ);
let currentDt = startDt;
while (currentDt <= endDt) {
while (currentDt < endDt) {
allDatesInRange.push(currentDt.toFormat('yyyy-MM-dd'));
currentDt = currentDt.plus({ days: 1 });
}
@@ -255,7 +262,8 @@ router.get('/', async (req, res) => {
const picking = pickingByDate.get(date) || { ordersPicked: 0, piecesPicked: 0 };
const shippingData = shippingByDate.get(date) || { ordersShipped: 0, piecesShipped: 0 };
// Parse date string in Eastern timezone to get proper ISO timestamp
// The string is already the business date; the zone here is only for
// the display timestamp label (office time).
const dateDt = DateTime.fromFormat(date, 'yyyy-MM-dd', { zone: TIMEZONE });
return {
@@ -446,22 +454,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());
}
function getPreviousTimeRange(timeRange) {
@@ -1,11 +1,20 @@
import express from 'express';
import { DateTime } from 'luxon';
import { getDbConnection, getPoolStatus } from '../db/connection.js';
import { parseMySql, toMySqlBound } from '../../../shared/business-time/index.js';
const router = express.Router();
const TIMEZONE = 'America/New_York';
// timeclock.TimeStamp stores Central wall-clock literals; the driver returns
// them as strings (dateStrings: true). Parse in business time, compare/display
// in ET (pay-period boundaries are an ET display convention).
const punchDateTime = (punch) => {
const dt = parseMySql(punch.TimeStamp);
return dt ? dt.setZone(TIMEZONE) : null;
};
// Punch types from the database
const PUNCH_TYPES = {
OUT: 0,
@@ -108,17 +117,17 @@ function calculateHoursByWeek(punches, payPeriod) {
byEmployee.forEach((employeeData) => {
// Sort punches by timestamp
employeeData.punches.sort((a, b) => new Date(a.TimeStamp) - new Date(b.TimeStamp));
employeeData.punches.sort((a, b) => (punchDateTime(a)?.toMillis() ?? 0) - (punchDateTime(b)?.toMillis() ?? 0));
// Calculate hours for each week
const week1Punches = employeeData.punches.filter(p => {
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
return dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
const dt = punchDateTime(p);
return dt && dt >= payPeriod.week1.start && dt <= payPeriod.week1.end;
});
const week2Punches = employeeData.punches.filter(p => {
const dt = DateTime.fromJSDate(new Date(p.TimeStamp), { zone: TIMEZONE });
return dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
const dt = punchDateTime(p);
return dt && dt >= payPeriod.week2.start && dt <= payPeriod.week2.end;
});
const week1Hours = calculateHoursFromPunches(week1Punches);
@@ -205,7 +214,7 @@ function calculateHoursFromPunches(punches) {
let breakStart = null;
punches.forEach(punch => {
const punchTime = new Date(punch.TimeStamp);
const punchTime = punchDateTime(punch)?.toJSDate() ?? new Date(NaN);
switch (punch.PunchType) {
case PUNCH_TYPES.IN:
@@ -283,9 +292,11 @@ router.get('/', async (req, res) => {
console.log(`[PAYROLL-METRICS] DB connection obtained in ${Date.now() - startTime}ms`);
try {
// Build query for the pay period
const periodStart = payPeriod.start.toJSDate();
const periodEnd = payPeriod.end.toJSDate();
// Build query for the pay period. Bounds are serialized to Central
// wall-clock strings (the column's literal zone), half-open
// [start, start + 14 days).
const periodStart = toMySqlBound(payPeriod.start);
const periodEnd = toMySqlBound(payPeriod.start.plus({ days: 14 }));
const timeclockQuery = `
SELECT
@@ -296,7 +307,7 @@ router.get('/', async (req, res) => {
e.lastname
FROM timeclock tc
LEFT JOIN employees e ON tc.EmployeeID = e.employeeid
WHERE tc.TimeStamp >= ? AND tc.TimeStamp <= ?
WHERE tc.TimeStamp >= ? AND tc.TimeStamp < ?
AND e.hidden = 0
AND e.disabled = 0
ORDER BY tc.EmployeeID, tc.TimeStamp
@@ -322,8 +333,8 @@ router.get('/', async (req, res) => {
// Get previous pay period data for comparison
const prevPayPeriod = navigatePayPeriod(payPeriod.start, -1);
const [prevTimeclockRows] = await connection.execute(timeclockQuery, [
prevPayPeriod.start.toJSDate(),
prevPayPeriod.end.toJSDate(),
toMySqlBound(prevPayPeriod.start),
toMySqlBound(prevPayPeriod.start.plus({ days: 14 })),
]);
const prevHoursData = calculateHoursByWeek(prevTimeclockRows, prevPayPeriod);
@@ -1,317 +0,0 @@
import { DateTime } from 'luxon';
const TIMEZONE = 'America/New_York';
const DB_TIMEZONE = 'UTC-05:00';
const BUSINESS_DAY_START_HOUR = 1; // 1 AM Eastern
const WEEK_START_DAY = 7; // Sunday (Luxon uses 1 = Monday, 7 = Sunday)
const DB_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss';
const isDateTime = (value) => DateTime.isDateTime(value);
const ensureDateTime = (value, { zone = TIMEZONE } = {}) => {
if (!value) return null;
if (isDateTime(value)) {
return value.setZone(zone);
}
if (value instanceof Date) {
return DateTime.fromJSDate(value, { zone });
}
if (typeof value === 'number') {
return DateTime.fromMillis(value, { zone });
}
if (typeof value === 'string') {
let dt = DateTime.fromISO(value, { zone, setZone: true });
if (!dt.isValid) {
dt = DateTime.fromSQL(value, { zone });
}
return dt.isValid ? dt : null;
}
return null;
};
const getNow = () => DateTime.now().setZone(TIMEZONE);
const getDayStart = (input = getNow()) => {
const dt = ensureDateTime(input);
if (!dt || !dt.isValid) {
const fallback = getNow();
return fallback.set({
hour: BUSINESS_DAY_START_HOUR,
minute: 0,
second: 0,
millisecond: 0
});
}
const sameDayStart = dt.set({
hour: BUSINESS_DAY_START_HOUR,
minute: 0,
second: 0,
millisecond: 0
});
return dt.hour < BUSINESS_DAY_START_HOUR
? sameDayStart.minus({ days: 1 })
: sameDayStart;
};
const getDayEnd = (input = getNow()) => {
return getDayStart(input).plus({ days: 1 }).minus({ milliseconds: 1 });
};
const getWeekStart = (input = getNow()) => {
const dt = ensureDateTime(input);
if (!dt || !dt.isValid) {
return getDayStart();
}
const startOfWeek = dt.set({ weekday: WEEK_START_DAY }).startOf('day');
const normalized = startOfWeek > dt ? startOfWeek.minus({ weeks: 1 }) : startOfWeek;
return normalized.set({
hour: BUSINESS_DAY_START_HOUR,
minute: 0,
second: 0,
millisecond: 0
});
};
const getRangeForTimeRange = (timeRange = 'today', now = getNow()) => {
const current = ensureDateTime(now);
if (!current || !current.isValid) {
throw new Error('Invalid reference time for range calculation');
}
switch (timeRange) {
case 'today': {
return {
start: getDayStart(current),
end: getDayEnd(current)
};
}
case 'yesterday': {
const target = current.minus({ days: 1 });
return {
start: getDayStart(target),
end: getDayEnd(target)
};
}
case 'twoDaysAgo': {
const target = current.minus({ days: 2 });
return {
start: getDayStart(target),
end: getDayEnd(target)
};
}
case 'thisWeek': {
return {
start: getWeekStart(current),
end: getDayEnd(current)
};
}
case 'lastWeek': {
const lastWeek = current.minus({ weeks: 1 });
const weekStart = getWeekStart(lastWeek);
const weekEnd = weekStart.plus({ days: 6 });
return {
start: weekStart,
end: getDayEnd(weekEnd)
};
}
case 'thisMonth': {
const dayStart = getDayStart(current);
const monthStart = dayStart.startOf('month').set({ hour: BUSINESS_DAY_START_HOUR });
return {
start: monthStart,
end: getDayEnd(current)
};
}
case 'lastMonth': {
const lastMonth = current.minus({ months: 1 });
const monthStart = lastMonth
.startOf('month')
.set({ hour: BUSINESS_DAY_START_HOUR, minute: 0, second: 0, millisecond: 0 });
const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 });
return {
start: monthStart,
end: getDayEnd(monthEnd)
};
}
case 'last7days': {
const dayStart = getDayStart(current);
return {
start: dayStart.minus({ days: 6 }),
end: getDayEnd(current)
};
}
case 'last30days': {
const dayStart = getDayStart(current);
return {
start: dayStart.minus({ days: 29 }),
end: getDayEnd(current)
};
}
case 'last90days': {
const dayStart = getDayStart(current);
return {
start: dayStart.minus({ days: 89 }),
end: getDayEnd(current)
};
}
case 'previous7days': {
const currentPeriodStart = getDayStart(current).minus({ days: 6 });
const previousEndDay = currentPeriodStart.minus({ days: 1 });
const previousStartDay = previousEndDay.minus({ days: 6 });
return {
start: getDayStart(previousStartDay),
end: getDayEnd(previousEndDay)
};
}
case 'previous30days': {
const currentPeriodStart = getDayStart(current).minus({ days: 29 });
const previousEndDay = currentPeriodStart.minus({ days: 1 });
const previousStartDay = previousEndDay.minus({ days: 29 });
return {
start: getDayStart(previousStartDay),
end: getDayEnd(previousEndDay)
};
}
case 'previous90days': {
const currentPeriodStart = getDayStart(current).minus({ days: 89 });
const previousEndDay = currentPeriodStart.minus({ days: 1 });
const previousStartDay = previousEndDay.minus({ days: 89 });
return {
start: getDayStart(previousStartDay),
end: getDayEnd(previousEndDay)
};
}
default:
throw new Error(`Unknown time range: ${timeRange}`);
}
};
const toDatabaseSqlString = (dt) => {
const normalized = ensureDateTime(dt);
if (!normalized || !normalized.isValid) {
throw new Error('Invalid datetime provided for SQL conversion');
}
const dbTime = normalized.setZone(DB_TIMEZONE, { keepLocalTime: true });
return dbTime.toFormat(DB_DATETIME_FORMAT);
};
const formatBusinessDate = (input) => {
const dt = ensureDateTime(input);
if (!dt || !dt.isValid) return '';
return dt.setZone(TIMEZONE).toFormat('LLL d, yyyy');
};
const getTimeRangeLabel = (timeRange) => {
const labels = {
today: 'Today',
yesterday: 'Yesterday',
twoDaysAgo: 'Two Days Ago',
thisWeek: 'This Week',
lastWeek: 'Last Week',
thisMonth: 'This Month',
lastMonth: 'Last Month',
last7days: 'Last 7 Days',
last30days: 'Last 30 Days',
last90days: 'Last 90 Days',
previous7days: 'Previous 7 Days',
previous30days: 'Previous 30 Days',
previous90days: 'Previous 90 Days'
};
return labels[timeRange] || timeRange;
};
const getTimeRangeConditions = (timeRange, startDate, endDate) => {
if (timeRange === 'custom' && startDate && endDate) {
const start = ensureDateTime(startDate);
const end = ensureDateTime(endDate);
if (!start || !start.isValid || !end || !end.isValid) {
throw new Error('Invalid custom date range provided');
}
return {
whereClause: 'date_placed >= ? AND date_placed <= ?',
params: [toDatabaseSqlString(start), toDatabaseSqlString(end)],
dateRange: {
start: start.toUTC().toISO(),
end: end.toUTC().toISO(),
label: `${formatBusinessDate(start)} - ${formatBusinessDate(end)}`
}
};
}
const normalizedRange = timeRange || 'today';
const range = getRangeForTimeRange(normalizedRange);
return {
whereClause: 'date_placed >= ? AND date_placed <= ?',
params: [toDatabaseSqlString(range.start), toDatabaseSqlString(range.end)],
dateRange: {
start: range.start.toUTC().toISO(),
end: range.end.toUTC().toISO(),
label: getTimeRangeLabel(normalizedRange)
}
};
};
const getBusinessDayBounds = (timeRange) => {
const range = getRangeForTimeRange(timeRange);
return {
start: range.start.toJSDate(),
end: range.end.toJSDate()
};
};
const parseBusinessDate = (mysqlDatetime) => {
if (!mysqlDatetime || mysqlDatetime === '0000-00-00 00:00:00') {
return null;
}
const dt = DateTime.fromSQL(mysqlDatetime, { zone: DB_TIMEZONE });
if (!dt.isValid) {
console.error('[timeUtils] Failed to parse MySQL datetime:', mysqlDatetime, dt.invalidExplanation);
return null;
}
return dt.toUTC().toJSDate();
};
const formatMySQLDate = (input) => {
if (!input) return null;
const dt = ensureDateTime(input, { zone: 'utc' });
if (!dt || !dt.isValid) return null;
return dt.setZone(DB_TIMEZONE).toFormat(DB_DATETIME_FORMAT);
};
// Expose helpers for tests or advanced consumers.
// Kept as a named `_internal` export so existing destructuring sites
// (`const { _internal: timeHelpers } = require(...)` → ESM equivalent works)
// don't need to change beyond the import-statement rewrite.
const _internal = {
getDayStart,
getDayEnd,
getWeekStart,
getRangeForTimeRange,
BUSINESS_DAY_START_HOUR,
};
export {
getBusinessDayBounds,
getTimeRangeConditions,
formatBusinessDate,
getTimeRangeLabel,
parseBusinessDate,
formatMySQLDate,
_internal,
};