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,
};
@@ -1,6 +1,6 @@
import express from 'express';
import { CampaignsService } from '../../services/klaviyo/campaigns.service.js';
import { TimeManager } from '../../utils/time.utils.js';
import { TimeManager } from '../../../shared/business-time/index.js';
export function createCampaignsRouter(apiKey, apiRevision, redis) {
const router = express.Router();
@@ -1,6 +1,6 @@
import express from 'express';
import { EventsService } from '../../services/klaviyo/events.service.js';
import { TimeManager } from '../../utils/time.utils.js';
import { TimeManager } from '../../../shared/business-time/index.js';
import { RedisService } from '../../services/klaviyo/redis.service.js';
import { requirePermission } from '../../../shared/auth/middleware.js';
@@ -1,6 +1,6 @@
import express from 'express';
import { ReportingService } from '../../services/klaviyo/reporting.service.js';
import { TimeManager } from '../../utils/time.utils.js';
import { TimeManager } from '../../../shared/business-time/index.js';
export function createReportingRouter(apiKey, apiRevision, redis) {
const router = express.Router();
@@ -1,5 +1,5 @@
import fetch from 'node-fetch';
import { TimeManager } from '../../utils/time.utils.js';
import { TimeManager } from '../../../shared/business-time/index.js';
import { RedisService } from './redis.service.js';
export class CampaignsService {
@@ -1,5 +1,5 @@
import fetch from 'node-fetch';
import { TimeManager } from '../../utils/time.utils.js';
import { TimeManager } from '../../../shared/business-time/index.js';
import { RedisService } from './redis.service.js';
import _ from 'lodash';
@@ -14,7 +14,7 @@
// Reads short-circuit to null when the client isn't ready; writes are no-ops.
// Same "Redis hiccup → fall through to upstream" behavior as before.
import { TimeManager } from '../../utils/time.utils.js';
import { TimeManager } from '../../../shared/business-time/index.js';
export class RedisService {
constructor(redis) {
@@ -1,5 +1,5 @@
import fetch from 'node-fetch';
import { TimeManager } from '../../utils/time.utils.js';
import { TimeManager } from '../../../shared/business-time/index.js';
import { RedisService } from './redis.service.js';
const METRIC_IDS = {
@@ -1,448 +0,0 @@
import { DateTime } from 'luxon';
export class TimeManager {
constructor(dayStartHour = 1) {
this.timezone = 'America/New_York';
this.dayStartHour = dayStartHour; // Hour (0-23) when the business day starts
this.weekStartDay = 7; // 7 = Sunday in Luxon
}
/**
* Get the start of the current business day
* If current time is before dayStartHour, return previous day at dayStartHour
*/
getDayStart(dt = this.getNow()) {
if (!dt.isValid) {
console.error("[TimeManager] Invalid datetime provided to getDayStart");
return this.getNow();
}
const dayStart = dt.set({ hour: this.dayStartHour, minute: 0, second: 0, millisecond: 0 });
return dt.hour < this.dayStartHour ? dayStart.minus({ days: 1 }) : dayStart;
}
/**
* Get the end of the current business day
* End is defined as dayStartHour - 1 minute on the next day
*/
getDayEnd(dt = this.getNow()) {
if (!dt.isValid) {
console.error("[TimeManager] Invalid datetime provided to getDayEnd");
return this.getNow();
}
const nextDay = this.getDayStart(dt).plus({ days: 1 });
return nextDay.minus({ minutes: 1 });
}
/**
* Get the start of the week containing the given date
* Aligns with custom day start time and starts on Sunday
*/
getWeekStart(dt = this.getNow()) {
if (!dt.isValid) {
console.error("[TimeManager] Invalid datetime provided to getWeekStart");
return this.getNow();
}
// Set to start of week (Sunday) and adjust hour
const weekStart = dt.set({ weekday: this.weekStartDay }).startOf('day');
// If the week start time would be after the given time, go back a week
if (weekStart > dt) {
return weekStart.minus({ weeks: 1 }).set({ hour: this.dayStartHour });
}
return weekStart.set({ hour: this.dayStartHour });
}
/**
* Convert any date input to a Luxon DateTime in Eastern time
*/
toDateTime(date) {
if (!date) return null;
if (date instanceof DateTime) {
return date.setZone(this.timezone);
}
// If it's an ISO string or Date object, parse it
const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date);
if (!dt.isValid) {
console.error("[TimeManager] Invalid date input:", date);
return null;
}
return dt.setZone(this.timezone);
}
/**
* Format a date for API requests (UTC ISO string)
*/
formatForAPI(date) {
if (!date) return null;
// Parse the input date
const dt = this.toDateTime(date);
if (!dt || !dt.isValid) {
console.error("[TimeManager] Invalid date for API:", date);
return null;
}
// Convert to UTC for API request
const utc = dt.toUTC();
console.log("[TimeManager] API date conversion:", {
input: date,
eastern: dt.toISO(),
utc: utc.toISO(),
offset: dt.offset
});
return utc.toISO();
}
/**
* Format a date for display (in Eastern time)
*/
formatForDisplay(date) {
const dt = this.toDateTime(date);
if (!dt || !dt.isValid) return '';
return dt.toFormat('LLL d, yyyy h:mm a');
}
/**
* Validate if a date range is valid
*/
isValidDateRange(start, end) {
const startDt = this.toDateTime(start);
const endDt = this.toDateTime(end);
return startDt && endDt && endDt > startDt;
}
/**
* Get the current time in Eastern timezone
*/
getNow() {
return DateTime.now().setZone(this.timezone);
}
/**
* Get a date range for the last N hours
*/
getLastNHours(hours) {
const now = this.getNow();
return {
start: now.minus({ hours }),
end: now
};
}
/**
* Get a date range for the last N days
* Aligns with custom day start time
*/
getLastNDays(days) {
const now = this.getNow();
const dayStart = this.getDayStart(now);
return {
start: dayStart.minus({ days }),
end: this.getDayEnd(now)
};
}
/**
* Get a date range for a specific time period
* All ranges align with custom day start time
*/
getDateRange(period) {
const now = this.getNow();
// Normalize period to handle both 'last' and 'previous' prefixes
const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period;
switch (normalizedPeriod) {
case 'custom': {
// Custom ranges are handled separately via getCustomRange
console.warn('[TimeManager] Custom ranges should use getCustomRange method');
return null;
}
case 'today': {
const dayStart = this.getDayStart(now);
return {
start: dayStart,
end: this.getDayEnd(now)
};
}
case 'yesterday': {
const yesterday = now.minus({ days: 1 });
return {
start: this.getDayStart(yesterday),
end: this.getDayEnd(yesterday)
};
}
case 'last7days': {
// For last 7 days, we want to include today and the previous 6 days
const dayStart = this.getDayStart(now);
const weekStart = dayStart.minus({ days: 6 });
return {
start: weekStart,
end: this.getDayEnd(now)
};
}
case 'last30days': {
// Include today and previous 29 days
const dayStart = this.getDayStart(now);
const monthStart = dayStart.minus({ days: 29 });
return {
start: monthStart,
end: this.getDayEnd(now)
};
}
case 'last90days': {
// Include today and previous 89 days
const dayStart = this.getDayStart(now);
const start = dayStart.minus({ days: 89 });
return {
start,
end: this.getDayEnd(now)
};
}
case 'thisWeek': {
// Get the start of the week (Sunday) with custom hour
const weekStart = this.getWeekStart(now);
return {
start: weekStart,
end: this.getDayEnd(now)
};
}
case 'lastWeek': {
const lastWeek = now.minus({ weeks: 1 });
const weekStart = this.getWeekStart(lastWeek);
const weekEnd = weekStart.plus({ days: 6 }); // 6 days after start = Saturday
return {
start: weekStart,
end: this.getDayEnd(weekEnd)
};
}
case 'thisMonth': {
const dayStart = this.getDayStart(now);
const monthStart = dayStart.startOf('month').set({ hour: this.dayStartHour });
return {
start: monthStart,
end: this.getDayEnd(now)
};
}
case 'lastMonth': {
const lastMonth = now.minus({ months: 1 });
const monthStart = lastMonth.startOf('month').set({ hour: this.dayStartHour });
const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 });
return {
start: monthStart,
end: this.getDayEnd(monthEnd)
};
}
default:
console.warn(`[TimeManager] Unknown period: ${period}`);
return null;
}
}
/**
* Format a duration in milliseconds to a human-readable string
*/
formatDuration(ms) {
return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
}
/**
* Get relative time string (e.g., "2 hours ago")
*/
getRelativeTime(date) {
const dt = this.toDateTime(date);
if (!dt) return '';
return dt.toRelative();
}
/**
* Get a custom date range using exact dates and times provided
* @param {string} startDate - ISO string or Date for range start
* @param {string} endDate - ISO string or Date for range end
* @returns {Object} Object with start and end DateTime objects
*/
getCustomRange(startDate, endDate) {
if (!startDate || !endDate) {
console.error("[TimeManager] Custom range requires both start and end dates");
return null;
}
const start = this.toDateTime(startDate);
const end = this.toDateTime(endDate);
if (!start || !end || !start.isValid || !end.isValid) {
console.error("[TimeManager] Invalid dates provided for custom range");
return null;
}
// Validate the range
if (end < start) {
console.error("[TimeManager] End date must be after start date");
return null;
}
return {
start,
end
};
}
/**
* Get the previous period's date range based on the current period
* @param {string} period - The current period
* @param {DateTime} now - The current datetime (optional)
* @returns {Object} Object with start and end DateTime objects
*/
getPreviousPeriod(period, now = this.getNow()) {
const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period;
switch (normalizedPeriod) {
case 'today': {
const yesterday = now.minus({ days: 1 });
return {
start: this.getDayStart(yesterday),
end: this.getDayEnd(yesterday)
};
}
case 'yesterday': {
const twoDaysAgo = now.minus({ days: 2 });
return {
start: this.getDayStart(twoDaysAgo),
end: this.getDayEnd(twoDaysAgo)
};
}
case 'last7days': {
const dayStart = this.getDayStart(now);
const currentStart = dayStart.minus({ days: 6 });
const prevEnd = currentStart.minus({ milliseconds: 1 });
const prevStart = prevEnd.minus({ days: 6 });
return {
start: prevStart,
end: prevEnd
};
}
case 'last30days': {
const dayStart = this.getDayStart(now);
const currentStart = dayStart.minus({ days: 29 });
const prevEnd = currentStart.minus({ milliseconds: 1 });
const prevStart = prevEnd.minus({ days: 29 });
return {
start: prevStart,
end: prevEnd
};
}
case 'last90days': {
const dayStart = this.getDayStart(now);
const currentStart = dayStart.minus({ days: 89 });
const prevEnd = currentStart.minus({ milliseconds: 1 });
const prevStart = prevEnd.minus({ days: 89 });
return {
start: prevStart,
end: prevEnd
};
}
case 'thisWeek': {
const weekStart = this.getWeekStart(now);
const prevEnd = weekStart.minus({ milliseconds: 1 });
const prevStart = this.getWeekStart(prevEnd);
return {
start: prevStart,
end: prevEnd
};
}
case 'lastWeek': {
const lastWeekStart = this.getWeekStart(now.minus({ weeks: 1 }));
const prevEnd = lastWeekStart.minus({ milliseconds: 1 });
const prevStart = this.getWeekStart(prevEnd);
return {
start: prevStart,
end: prevEnd
};
}
case 'thisMonth': {
const monthStart = now.startOf('month').set({ hour: this.dayStartHour });
const prevEnd = monthStart.minus({ milliseconds: 1 });
const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour });
return {
start: prevStart,
end: prevEnd
};
}
case 'lastMonth': {
const lastMonthStart = now.minus({ months: 1 }).startOf('month').set({ hour: this.dayStartHour });
const prevEnd = lastMonthStart.minus({ milliseconds: 1 });
const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour });
return {
start: prevStart,
end: prevEnd
};
}
default:
console.warn(`[TimeManager] No previous period defined for: ${period}`);
return null;
}
}
groupEventsByInterval(events, interval = 'day', property = null) {
if (!events?.length) return [];
const groupedData = new Map();
const now = DateTime.now().setZone('America/New_York');
for (const event of events) {
const datetime = DateTime.fromISO(event.attributes.datetime);
let groupKey;
switch (interval) {
case 'hour':
groupKey = datetime.startOf('hour').toISO();
break;
case 'day':
groupKey = datetime.startOf('day').toISO();
break;
case 'week':
groupKey = datetime.startOf('week').toISO();
break;
case 'month':
groupKey = datetime.startOf('month').toISO();
break;
default:
groupKey = datetime.startOf('day').toISO();
}
const existingGroup = groupedData.get(groupKey) || {
datetime: groupKey,
count: 0,
value: 0
};
existingGroup.count++;
if (property) {
// Extract property value from event
const props = event.attributes?.event_properties || event.attributes?.properties || {};
let value = 0;
if (property === '$value') {
// Special case for $value - use event value
value = Number(event.attributes?.value || 0);
} else {
// Otherwise get from properties
value = Number(props[property] || 0);
}
existingGroup.value = (existingGroup.value || 0) + value;
}
groupedData.set(groupKey, existingGroup);
}
// Convert to array and sort by datetime
return Array.from(groupedData.values())
.sort((a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime));
}
}
+67
View File
@@ -0,0 +1,67 @@
# TIME.md — the business-time convention
Adopted 2026-06-12 (see `TIME_UNIFICATION_PLAN.md` at the repo root for the
audit and rollout). **Never hand-roll an hour offset.**
## The convention
**A business day runs 1:00am Eastern → 1:00am Eastern the next day.**
Because 1am Eastern == midnight Central year-round (both observe US DST),
this is identical to:
> **Business date = the America/Chicago calendar date.**
- `BUSINESS_TZ = 'America/Chicago'` — canonical, used for all boundary math.
- `OFFICE_TZ = 'America/New_York'` — display only (the office reads ET).
The single source of truth is `shared/business-time/index.js`
(`@inventory/shared/business-time`). The frontend mirror for the few
client-side needs is `inventory/src/utils/businessTime.ts`.
## Approved idioms
| Context | Correct idiom |
| --- | --- |
| PG `timestamptz` → business date | `(ts AT TIME ZONE 'America/Chicago')::date`, or plain `ts::date` / `CURRENT_DATE` (the session TZ is pinned to America/Chicago in every pool config AND as the `inventory_db` database default) |
| MySQL DATETIME literal (stores **Central wall-clock**) → business date | `DATE(col)` / `DATE_FORMAT(col, '%Y-%m-%d')`**no hour shift** |
| MySQL WHERE bounds for business days | Central wall-clock strings via `toMySqlBound()`: `col >= ? AND col < ?` |
| MySQL hour-of-day for the office | `HOUR(ADDTIME(col, '01:00:00'))` — ET = CT + 1h year-round |
| Luxon | `dt.setZone('America/Chicago')` (real conversion). Never `keepLocalTime`, never a fixed `UTC-05:00` offset (wrong every winter) |
| Intervals | **Half-open** `[start, nextStart)`. No `-1 minute` / `-1 ms` endpoints (legacy exception: the `TimeManager` compat class keeps an inclusive 1ms end for its existing consumers) |
| "Today" in JS | `businessTodayStr()` / `businessToday()` from the shared module — never `new Date().toISOString().split('T')[0]` (UTC) |
| node-postgres DATE columns → string | `formatDateCol(value)` (local-time formatting round-trips correctly) or `::text` in SQL — never `.toISOString().split('T')[0]` (UTC shifts a day) |
| mysql2 driver config | `dateStrings: true` + parse with `parseMySql()` (zone Chicago). The import pipeline instead pins a **dynamic** Chicago offset (`currentChicagoOffset()` in `scripts/import-from-prod.js`) |
| Client → server | Named range presets, or **date-only strings** (`YYYY-MM-DD` = business dates). Never `toISOString()` of a browser-local midnight |
## Range vocabulary (both servers, one list)
`today, yesterday, twoDaysAgo, thisWeek, lastWeek, thisMonth, lastMonth,
last7days, last30days, last90days, previous7days, previous30days,
previous90days, custom`
`custom` takes `startDate`/`endDate` as date-only business dates (preferred)
or zoned ISO instants (used as-is, end exclusive). Calendar periods
("March 2026") mean the business month: Mar 1 1am ET → Apr 1 1am ET.
## Accepted deviations (documented, not fought)
- **GA4**: property TZ = America/New_York; `NdaysAgo` = calendar days,
midnight ET. Affects only 0:001:00am ET traffic attribution.
- **Meta insights**: `time_zone: 'America/New_York'` — same 1-hour deviation,
same note.
- **Klaviyo**: fetched with exact UTC bounds computed from our own range math
— fully on-convention. API returns UTC datetimes.
- **Pay periods** (payroll dashboard): 14-day periods aligned to **midnight
ET** Sundays — an HR display convention, intentionally not 1am-ET days.
## Infrastructure pins (belt-and-suspenders)
1. `ALTER DATABASE inventory_db SET timezone = 'America/Chicago'` (database default).
2. Every pg Pool passes `options: '-c TimeZone=America/Chicago'`
(`src/utils/db.js`, `shared/db/pg.js`, `scripts/metrics-new/utils/db.js`,
`scripts/calculate-metrics-new.js`, `scripts/import/utils.js`).
3. Node processes run with `TZ=America/Chicago`
(`/var/www/ecosystem.config.cjs` env blocks; `scripts/full-update.js` and
`scripts/forecast/run_forecast.js` set it themselves for cron contexts).
4. `forecast_engine.py` anchors on `business_today()` (zoneinfo Chicago).
+10
View File
@@ -21,6 +21,7 @@
"express-rate-limit": "^7.4.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2",
"luxon": "^3.7.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0",
"openai": "^6.0.0",
@@ -3658,6 +3659,15 @@
"url": "https://github.com/sponsors/wellwelwel"
}
},
"node_modules/luxon": {
"version": "3.7.2",
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz",
"integrity": "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+1
View File
@@ -32,6 +32,7 @@
"express-rate-limit": "^7.4.0",
"ioredis": "^5.10.1",
"jsonwebtoken": "^9.0.2",
"luxon": "^3.7.2",
"multer": "^1.4.5-lts.1",
"mysql2": "^3.12.0",
"openai": "^6.0.0",
@@ -140,6 +140,12 @@ console.log('DB Connection Info:', {
password: (dbConfig.password || dbConfig.connectionString) ? '****' : 'MISSING' // Only show if credentials exist
});
// Pin the session timezone to business time (see docs/TIME.md) — window
// anchors (CURRENT_DATE etc.) in the metrics SQL must mean Chicago dates.
if (!dbConfig.options) {
dbConfig.options = '-c TimeZone=America/Chicago';
}
const pool = new Pool(dbConfig);
const getConnection = () => {
@@ -18,6 +18,18 @@ import json
import time
import logging
from datetime import datetime, date, timedelta
from zoneinfo import ZoneInfo
BUSINESS_TZ = ZoneInfo('America/Chicago')
def business_today() -> date:
"""The business date (Chicago calendar date — see docs/TIME.md).
Never business_today(): the server runs on Europe/Berlin, which flips to the
next day at 5/6pm Central and would shift forecast_date anchors.
"""
return datetime.now(BUSINESS_TZ).date()
import numpy as np
import pandas as pd
@@ -740,7 +752,7 @@ def batch_load_product_data(conn, products):
GROUP BY pid
"""
adf = execute_query(conn, arrival_sql, [preorder_pids])
today = date.today()
today = business_today()
for _, row in adf.iterrows():
pid = int(row['pid'])
fa = row['future_arrival']
@@ -948,7 +960,7 @@ def forecast_mature(product, history_df):
hist['snapshot_date'] = pd.to_datetime(hist['snapshot_date'])
hist = hist.set_index('snapshot_date')['units_sold']
full_index = pd.date_range(
end=pd.Timestamp(date.today() - timedelta(days=1)),
end=pd.Timestamp(business_today() - timedelta(days=1)),
periods=EXP_SMOOTHING_WINDOW, freq='D')
series = hist.reindex(full_index, fill_value=0.0).values.astype(float)
@@ -1070,7 +1082,7 @@ def generate_all_forecasts(conn, curves_df, dow_indices, monthly_indices=None,
log.info("Batch loading product data...")
batch_data = batch_load_product_data(conn, products)
today = date.today()
today = business_today()
forecast_dates = [today + timedelta(days=i) for i in range(FORECAST_HORIZON_DAYS)]
# Pre-compute DOW and seasonal multipliers for each forecast date.
@@ -1676,7 +1688,7 @@ def backfill_accuracy_data(conn, backfill_days=30):
# Batch load product data for per-product scaling
batch_data = batch_load_product_data(conn, active)
today = date.today()
today = business_today()
backfill_start = today - timedelta(days=backfill_days)
# Create a synthetic run entry
@@ -14,6 +14,10 @@
* /var/www/inventory/.env (or current process env).
*/
// Run in business time (see docs/TIME.md); the spawned Python engine and any
// incidental Date math inherit it.
process.env.TZ = process.env.TZ || 'America/Chicago';
const { spawn } = require('child_process');
const path = require('path');
const fs = require('fs');
+4
View File
@@ -1,3 +1,7 @@
// Run the whole update cycle in business time (see docs/TIME.md). The cron
// that invokes this script does not set TZ; child scripts inherit it.
process.env.TZ = process.env.TZ || 'America/Chicago';
const path = require('path');
const fs = require('fs');
const { spawn } = require('child_process');
+17 -1
View File
@@ -23,6 +23,17 @@ const IMPORT_STOCK_SNAPSHOTS = true;
const INCREMENTAL_UPDATE = process.env.INCREMENTAL_UPDATE !== 'false'; // Default to true unless explicitly set to false
// SSH configuration
// Current UTC offset of America/Chicago ('-05:00' or '-06:00'), DST-aware.
function currentChicagoOffset() {
const tzName = new Intl.DateTimeFormat('en-US', {
timeZone: 'America/Chicago',
timeZoneName: 'longOffset',
})
.formatToParts(new Date())
.find((p) => p.type === 'timeZoneName').value; // e.g. 'GMT-05:00'
return tzName.replace('GMT', '') || '+00:00';
}
const sshConfig = {
ssh: {
host: process.env.PROD_SSH_HOST,
@@ -40,7 +51,12 @@ const sshConfig = {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: process.env.PROD_DB_PORT || 3306,
timezone: '-05:00', // mysql2 driver timezone — corrected at runtime via adjustDateForMySQL() in utils.js
// DATETIMEs on this server store Central wall-clock. Use the CURRENT
// Chicago offset (DST-aware) so both inbound parsing and outbound
// serialization are correct in winter (CST=-06:00) and summer
// (CDT=-05:00). adjustDateForMySQL() in utils.js remains as a runtime
// safety net (its correction is 0 when this offset matches the server).
timezone: currentChicagoOffset(),
},
localDbConfig: {
// PostgreSQL config for local
+13 -3
View File
@@ -58,7 +58,12 @@ async function setupConnections(sshConfig) {
"SELECT TIMESTAMPDIFF(SECOND, NOW(), UTC_TIMESTAMP()) as utcDiffSec"
);
const mysqlOffsetMs = -utcDiffSec * 1000; // MySQL UTC offset in ms (e.g., -21600000 for CST)
const driverOffsetMs = -5 * 3600 * 1000; // Driver's -05:00 in ms (-18000000)
// Driver offset from the actual config (e.g. '-06:00' in winter, '-05:00'
// in summer — set dynamically in import-from-prod.js).
const tzMatch = /^([+-])(\d{2}):(\d{2})$/.exec(sshConfig.prodDbConfig.timezone || '');
const driverOffsetMs = tzMatch
? (tzMatch[1] === '-' ? -1 : 1) * (Number(tzMatch[2]) * 3600 + Number(tzMatch[3]) * 60) * 1000
: -5 * 3600 * 1000;
const tzCorrectionMs = driverOffsetMs - mysqlOffsetMs;
// CST (winter): -18000000 - (-21600000) = +3600000 (1 hour correction needed)
// CDT (summer): -18000000 - (-18000000) = 0 (no correction needed)
@@ -79,8 +84,13 @@ async function setupConnections(sshConfig) {
}
prodConnection.adjustDateForMySQL = adjustDateForMySQL;
// Setup PostgreSQL connection pool for local
const localPool = new Pool(sshConfig.localDbConfig);
// Setup PostgreSQL connection pool for local.
// Pin the session timezone to business time (see docs/TIME.md) so import
// writes and any CURRENT_DATE/NOW()::date logic mean Chicago dates.
const localPool = new Pool({
options: '-c TimeZone=America/Chicago',
...sshConfig.localDbConfig,
});
// Test the PostgreSQL connection
try {
@@ -1,3 +1,7 @@
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
-- (pinned in the pool config and as the inventory_db database default).
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates aggregated metrics per brand.
-- Dependencies: product_metrics, products, calculate_status table.
-- Frequency: Daily (after product_metrics update).
@@ -1,3 +1,7 @@
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
-- (pinned in the pool config and as the inventory_db database default).
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates aggregated metrics per category with hierarchy rollups.
-- Dependencies: product_metrics, products, categories, product_categories, category_hierarchy, calculate_status table.
-- Frequency: Daily (after product_metrics update).
@@ -1,3 +1,7 @@
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
-- (pinned in the pool config and as the inventory_db database default).
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates aggregated metrics per vendor.
-- Dependencies: product_metrics, products, purchase_orders, calculate_status table.
-- Frequency: Daily (after product_metrics update).
@@ -1,3 +1,7 @@
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
-- (pinned in the pool config and as the inventory_db database default).
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates daily aggregated product data.
-- Self-healing: detects gaps (missing snapshots), stale data (snapshot
-- aggregates that don't match source tables after backfills), and always
@@ -1,3 +1,7 @@
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
-- (pinned in the pool config and as the inventory_db database default).
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Populates lifecycle forecast columns on product_metrics from product_forecasts.
-- Runs AFTER update_product_metrics.sql so that lead time / days of stock settings are available.
-- Dependencies: product_metrics (fully populated), product_forecasts, settings tables.
@@ -1,3 +1,7 @@
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
-- (pinned in the pool config and as the inventory_db database default).
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates metrics that don't need hourly updates, like ABC class
-- and average lead time.
-- Dependencies: product_metrics, purchase_orders, settings_global, calculate_status.
@@ -1,3 +1,7 @@
-- SESSION-TZ ASSUMPTION: this script runs with TimeZone=America/Chicago
-- (pinned in the pool config and as the inventory_db database default).
-- CURRENT_DATE / NOW()::date / ts::date here mean the BUSINESS date
-- (Chicago calendar day = 1am-Eastern day rule). See docs/TIME.md.
-- Description: Calculates and updates the main product_metrics table based on current data
-- and aggregated daily snapshots. Uses UPSERT for idempotency.
-- Dependencies: Core import tables, daily_product_snapshots, configuration tables, calculate_status.
@@ -10,6 +10,9 @@ const dbConfig = {
database: process.env.DB_NAME,
port: process.env.DB_PORT || 5432,
ssl: process.env.DB_SSL === 'true',
// Pin the session timezone to business time (see docs/TIME.md) — the
// metrics SQL window anchors (CURRENT_DATE etc.) must mean Chicago dates.
options: '-c TimeZone=America/Chicago',
// Add performance optimizations
max: 10, // connection pool max size
idleTimeoutMillis: 30000,
@@ -0,0 +1,562 @@
// Shared business-time module — the single source of truth for the time
// convention (see docs/TIME.md and TIME_UNIFICATION_PLAN.md).
//
// THE CONVENTION: a business day runs 1:00am Eastern → 1:00am Eastern the next
// day. Because 1am Eastern == midnight Central year-round (both observe US
// DST), this is identical to: business date = the America/Chicago calendar
// date. All boundary math here therefore uses Chicago days; Eastern exists
// only for display formatting.
//
// MySQL DATETIME columns on 192.168.1.5 store Central wall-clock literals, so
// toMySqlBound()/parseMySql() convert to/from Chicago wall-clock strings with
// no hour shift. Never hand-roll an hour offset and never use a fixed
// 'UTC-05:00' zone (wrong every winter).
import { DateTime } from 'luxon';
export const BUSINESS_TZ = 'America/Chicago'; // canonical
export const OFFICE_TZ = 'America/New_York'; // display only
const MYSQL_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss';
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
// ---------------------------------------------------------------------------
// Parsing / coercion
// ---------------------------------------------------------------------------
/**
* Coerce any supported input (DateTime, Date, millis, ISO string, SQL string)
* to a luxon DateTime in the business zone. Date-only strings are interpreted
* as business dates (Chicago midnight). Zoned ISO strings keep their instant.
* Returns null for unparseable input.
*/
export function toBusinessTime(value) {
if (value == null || value === '') return null;
if (DateTime.isDateTime(value)) return value.setZone(BUSINESS_TZ);
if (value instanceof Date) return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
if (typeof value === 'number') return DateTime.fromMillis(value).setZone(BUSINESS_TZ);
if (typeof value === 'string') {
let dt = DateTime.fromISO(value, { zone: BUSINESS_TZ, setZone: false });
if (!dt.isValid) dt = DateTime.fromSQL(value, { zone: BUSINESS_TZ });
return dt.isValid ? dt : null;
}
return null;
}
/** True if the input is a date-only string ('YYYY-MM-DD'). */
export const isDateOnly = (value) => typeof value === 'string' && DATE_ONLY_RE.test(value);
/**
* Parse a MySQL DATETIME/TIMESTAMP literal (Central wall-clock) into a
* DateTime in the business zone. Returns null for empty / zero dates.
*/
export function parseMySql(value) {
if (!value || value === '0000-00-00 00:00:00' || value === '0000-00-00') return null;
if (value instanceof Date) {
// Defensive: a driver not configured with dateStrings handed us a Date.
// Its instant depends on that driver's timezone config — pass it through
// as an instant rather than guessing.
return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
}
const dt = DateTime.fromSQL(String(value), { zone: BUSINESS_TZ });
return dt.isValid ? dt : null;
}
/**
* Serialize an instant to a MySQL bound: Central wall-clock
* 'YYYY-MM-DD HH:mm:ss'. Use with half-open intervals: col >= ? AND col < ?.
*/
export function toMySqlBound(value) {
const dt = toBusinessTime(value);
if (!dt) throw new Error('toMySqlBound: invalid datetime input');
return dt.toFormat(MYSQL_DATETIME_FORMAT);
}
// ---------------------------------------------------------------------------
// "Today" and day boundaries
// ---------------------------------------------------------------------------
/** Now, as a DateTime in the business zone. */
export const businessNow = () => DateTime.now().setZone(BUSINESS_TZ);
/** Start of the current business day (Chicago midnight = 1am Eastern). */
export const businessToday = () => businessNow().startOf('day');
/** The current business date as 'YYYY-MM-DD'. The only sanctioned "today". */
export const businessTodayStr = () => businessNow().toISODate();
/** Start of the business day containing the given instant/date. */
export function businessDayStart(value = businessNow()) {
const dt = toBusinessTime(value);
if (!dt) throw new Error('businessDayStart: invalid datetime input');
return dt.startOf('day');
}
/** Half-open range [start, nextStart) of the business day containing value. */
export function businessDayRange(value = businessNow()) {
const start = businessDayStart(value);
return { start, end: start.plus({ days: 1 }) };
}
/** Business date ('YYYY-MM-DD') of an instant. */
export function businessDateOf(value) {
const dt = toBusinessTime(value);
return dt ? dt.toISODate() : null;
}
/**
* Format a node-postgres DATE-column value as 'YYYY-MM-DD'.
*
* node-postgres materializes DATE columns as a JS Date at process-local
* midnight, so formatting in LOCAL time round-trips to the same calendar date
* regardless of the process zone. (The old .toISOString().split('T')[0] idiom
* formatted in UTC, which emitted the PREVIOUS day for any zone east of UTC.)
* Strings (e.g. from ::text casts) pass through.
*/
export function formatDateCol(value) {
if (value == null) return null;
if (typeof value === 'string') return value.slice(0, 10);
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return businessDateOf(value);
}
/** Format an instant in office (Eastern) time for display. */
export function formatOffice(value, fmt = 'LLL d, yyyy h:mm a') {
const dt = toBusinessTime(value);
return dt ? dt.setZone(OFFICE_TZ).toFormat(fmt) : '';
}
// ---------------------------------------------------------------------------
// Named range presets — ONE vocabulary for every server
// ---------------------------------------------------------------------------
export const RANGE_PRESETS = [
'today',
'yesterday',
'twoDaysAgo',
'thisWeek',
'lastWeek',
'thisMonth',
'lastMonth',
'last7days',
'last30days',
'last90days',
'previous7days',
'previous30days',
'previous90days',
'custom',
];
export const isKnownRange = (name) => RANGE_PRESETS.includes(name);
// Weeks start on Sunday (business convention).
function weekStart(dt) {
const day = dt.startOf('day');
// luxon weekday: 1 = Monday … 7 = Sunday
return day.weekday === 7 ? day : day.minus({ days: day.weekday });
}
/**
* Resolve a named preset to a half-open range { start, end } of business-zone
* DateTimes (end is EXCLUSIVE the start of the day after the last included
* business day). Throws on unknown names; 'custom' must be resolved by the
* caller via customRange().
*/
export function rangeForPreset(name, now = businessNow()) {
const current = toBusinessTime(now);
if (!current) throw new Error('rangeForPreset: invalid reference time');
const todayStart = current.startOf('day');
const tomorrowStart = todayStart.plus({ days: 1 });
switch (name) {
case 'today':
return { start: todayStart, end: tomorrowStart };
case 'yesterday':
return { start: todayStart.minus({ days: 1 }), end: todayStart };
case 'twoDaysAgo':
return { start: todayStart.minus({ days: 2 }), end: todayStart.minus({ days: 1 }) };
case 'thisWeek':
return { start: weekStart(current), end: tomorrowStart };
case 'lastWeek': {
const start = weekStart(current).minus({ weeks: 1 });
return { start, end: start.plus({ weeks: 1 }) };
}
case 'thisMonth':
return { start: todayStart.startOf('month'), end: tomorrowStart };
case 'lastMonth': {
const start = todayStart.startOf('month').minus({ months: 1 });
return { start, end: start.plus({ months: 1 }) };
}
case 'last7days':
return { start: todayStart.minus({ days: 6 }), end: tomorrowStart };
case 'last30days':
return { start: todayStart.minus({ days: 29 }), end: tomorrowStart };
case 'last90days':
return { start: todayStart.minus({ days: 89 }), end: tomorrowStart };
case 'previous7days': {
const currentStart = todayStart.minus({ days: 6 });
return { start: currentStart.minus({ days: 7 }), end: currentStart };
}
case 'previous30days': {
const currentStart = todayStart.minus({ days: 29 });
return { start: currentStart.minus({ days: 30 }), end: currentStart };
}
case 'previous90days': {
const currentStart = todayStart.minus({ days: 89 });
return { start: currentStart.minus({ days: 90 }), end: currentStart };
}
default:
throw new Error(`Unknown time range: ${name}`);
}
}
/**
* Resolve a custom range to half-open { start, end }. Date-only strings are
* business dates: start = that Chicago midnight, end = the day AFTER the end
* date (so both endpoints are included as full business days). Zoned ISO
* instants are used as-is (end treated as exclusive).
*/
export function customRange(startInput, endInput) {
if (!startInput || !endInput) throw new Error('customRange: both bounds required');
const start = isDateOnly(startInput)
? DateTime.fromISO(startInput, { zone: BUSINESS_TZ }).startOf('day')
: toBusinessTime(startInput);
let end = isDateOnly(endInput)
? DateTime.fromISO(endInput, { zone: BUSINESS_TZ }).startOf('day').plus({ days: 1 })
: toBusinessTime(endInput);
if (!start || !start.isValid || !end || !end.isValid) {
throw new Error('customRange: invalid bounds');
}
if (end < start) throw new Error('customRange: end before start');
return { start, end };
}
/**
* Previous comparable range. Accepts a preset name (uses the conventional
* predecessor: todayyesterday, thisWeeklastWeek, ) or a { start, end }
* half-open range (shifts back by its own duration).
*/
export function previousRange(nameOrRange, now = businessNow()) {
if (typeof nameOrRange === 'string') {
const map = {
today: 'yesterday',
yesterday: 'twoDaysAgo',
thisWeek: 'lastWeek',
thisMonth: 'lastMonth',
last7days: 'previous7days',
last30days: 'previous30days',
last90days: 'previous90days',
};
const prev = map[nameOrRange];
if (prev) return rangeForPreset(prev, now);
// Calendar-unit presets without a named predecessor.
if (nameOrRange === 'twoDaysAgo') {
const { start } = rangeForPreset('twoDaysAgo', now);
return { start: start.minus({ days: 1 }), end: start };
}
if (nameOrRange === 'lastWeek') {
const { start } = rangeForPreset('lastWeek', now);
return { start: start.minus({ weeks: 1 }), end: start };
}
if (nameOrRange === 'lastMonth') {
const { start } = rangeForPreset('lastMonth', now);
return { start: start.minus({ months: 1 }), end: start };
}
return null;
}
const { start, end } = nameOrRange || {};
if (!start || !end) return null;
const duration = end.diff(start);
return { start: start.minus(duration), end: start };
}
// ---------------------------------------------------------------------------
// MySQL query helpers (acot-server style)
// ---------------------------------------------------------------------------
const RANGE_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',
};
export const getTimeRangeLabel = (timeRange) => RANGE_LABELS[timeRange] || timeRange;
/** Eastern display date for a range label ('Jun 12, 2026'). */
export function formatBusinessDate(value) {
const dt = toBusinessTime(value);
return dt ? dt.setZone(OFFICE_TZ).toFormat('LLL d, yyyy') : '';
}
/**
* Build a half-open MySQL WHERE fragment + Central wall-clock bound params
* for a named or custom range. The where clause repeats the column for both
* bounds so callers re-targeting another column MUST replace globally
* (/date_placed/g).
*/
export function getTimeRangeConditions(timeRange, startDate, endDate) {
let range;
let label;
if (timeRange === 'custom' && startDate && endDate) {
range = customRange(startDate, endDate);
// Display the last *included* business day, not the exclusive endpoint.
label = `${formatBusinessDate(range.start)} - ${formatBusinessDate(range.end.minus({ milliseconds: 1 }))}`;
} else {
const normalized = timeRange || 'today';
range = rangeForPreset(normalized);
label = getTimeRangeLabel(normalized);
}
return {
whereClause: 'date_placed >= ? AND date_placed < ?',
params: [toMySqlBound(range.start), toMySqlBound(range.end)],
range,
start: range.start.toJSDate(),
end: range.end.toJSDate(), // EXCLUSIVE
dateRange: {
start: range.start.toUTC().toISO(),
end: range.end.toUTC().toISO(), // EXCLUSIVE
label,
},
};
}
/** Half-open JS Date bounds for a named range (end exclusive). */
export function getBusinessDayBounds(timeRange) {
const range = rangeForPreset(timeRange);
return { start: range.start.toJSDate(), end: range.end.toJSDate() };
}
/** Parse a MySQL datetime literal to a JS Date (legacy convenience). */
export function parseBusinessDate(mysqlDatetime) {
const dt = parseMySql(mysqlDatetime);
return dt ? dt.toUTC().toJSDate() : null;
}
/** Serialize any instant to a MySQL Central wall-clock string, null-safe. */
export function formatMySQLDate(value) {
const dt = toBusinessTime(value);
return dt ? dt.toFormat(MYSQL_DATETIME_FORMAT) : null;
}
// Legacy helpers kept for consumers that need raw day/week math. getDayEnd
// returns the half-open endpoint (next day start); prefer businessDayRange.
export const _internal = {
getDayStart: (input) => businessDayStart(input ?? businessNow()),
getDayEnd: (input) => businessDayStart(input ?? businessNow()).plus({ days: 1 }),
getWeekStart: (input) => weekStart(toBusinessTime(input ?? businessNow())),
getRangeForTimeRange: (timeRange, now) => rangeForPreset(timeRange, now),
BUSINESS_DAY_START_HOUR: 1, // 1am Eastern == midnight Central
};
// ---------------------------------------------------------------------------
// TimeManager — compatibility class for the dashboard/klaviyo tree.
//
// Same method surface as the old dashboard/utils/time.utils.js, with the
// defects fixed:
// - getDayEnd: was next-start minus 1 MINUTE (a 59.999s hole every day);
// now next-start minus 1 millisecond. Kept inclusive (rather than
// half-open) because consumers iterate `while (day <= end)` — a half-open
// end would add a spurious trailing bucket. Klaviyo API bounds use
// less-than(end), so the residual 1ms hole is negligible.
// - groupEventsByInterval: grouped in the event string's own zone; now
// groups days by business date and hour/week/month in office time.
// ---------------------------------------------------------------------------
export class TimeManager {
constructor(dayStartHour = 1) {
this.timezone = OFFICE_TZ;
this.dayStartHour = dayStartHour; // retained for API compat; 1 == Chicago midnight
this.weekStartDay = 7; // Sunday
}
getNow() {
return DateTime.now().setZone(this.timezone);
}
toDateTime(date) {
if (!date) return null;
if (DateTime.isDateTime(date)) return date.setZone(this.timezone);
const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date);
if (!dt.isValid) {
console.error('[TimeManager] Invalid date input:', date);
return null;
}
return dt.setZone(this.timezone);
}
getDayStart(dt = this.getNow()) {
if (!dt.isValid) return this.getNow();
return businessDayStart(dt).setZone(this.timezone);
}
getDayEnd(dt = this.getNow()) {
if (!dt.isValid) return this.getNow();
return businessDayStart(dt).plus({ days: 1 }).minus({ milliseconds: 1 }).setZone(this.timezone);
}
getWeekStart(dt = this.getNow()) {
if (!dt.isValid) return this.getNow();
return weekStart(toBusinessTime(dt)).setZone(this.timezone);
}
formatForAPI(date) {
const dt = this.toDateTime(date);
if (!dt || !dt.isValid) {
console.error('[TimeManager] Invalid date for API:', date);
return null;
}
return dt.toUTC().toISO();
}
formatForDisplay(date) {
const dt = this.toDateTime(date);
if (!dt || !dt.isValid) return '';
return dt.toFormat('LLL d, yyyy h:mm a');
}
isValidDateRange(start, end) {
const startDt = this.toDateTime(start);
const endDt = this.toDateTime(end);
return startDt && endDt && endDt > startDt;
}
getLastNHours(hours) {
const now = this.getNow();
return { start: now.minus({ hours }), end: now };
}
getLastNDays(days) {
const now = this.getNow();
return { start: this.getDayStart(now).minus({ days }), end: this.getDayEnd(now) };
}
getDateRange(period) {
if (period === 'custom') {
console.warn('[TimeManager] Custom ranges should use getCustomRange method');
return null;
}
const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
try {
const { start, end } = rangeForPreset(normalized);
return {
start: start.setZone(this.timezone),
end: end.minus({ milliseconds: 1 }).setZone(this.timezone),
};
} catch {
console.warn(`[TimeManager] Unknown period: ${period}`);
return null;
}
}
getCustomRange(startDate, endDate) {
if (!startDate || !endDate) {
console.error('[TimeManager] Custom range requires both start and end dates');
return null;
}
try {
const { start, end } = customRange(startDate, endDate);
const inclusiveEnd = isDateOnly(endDate) ? end.minus({ milliseconds: 1 }) : end;
return { start: start.setZone(this.timezone), end: inclusiveEnd.setZone(this.timezone) };
} catch (error) {
console.error('[TimeManager] Invalid dates provided for custom range:', error.message);
return null;
}
}
getPreviousPeriod(period, now = this.getNow()) {
const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
const current = (() => {
try {
return rangeForPreset(normalized, now);
} catch {
return null;
}
})();
if (!current) {
console.warn(`[TimeManager] No previous period defined for: ${period}`);
return null;
}
const prev =
previousRange(normalized, now) ||
previousRange(current, now);
if (!prev) return null;
return {
start: prev.start.setZone(this.timezone),
end: prev.end.minus({ milliseconds: 1 }).setZone(this.timezone),
};
}
formatDuration(ms) {
return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
}
getRelativeTime(date) {
const dt = this.toDateTime(date);
if (!dt) return '';
return dt.toRelative();
}
groupEventsByInterval(events, interval = 'day', property = null) {
if (!events?.length) return [];
const groupedData = new Map();
for (const event of events) {
const instant = DateTime.fromISO(event.attributes.datetime, { setZone: true });
if (!instant.isValid) continue;
let groupKey;
switch (interval) {
case 'hour':
groupKey = instant.setZone(OFFICE_TZ).startOf('hour').toISO();
break;
case 'week':
groupKey = weekStart(instant.setZone(BUSINESS_TZ)).setZone(OFFICE_TZ).toISO();
break;
case 'month':
groupKey = instant.setZone(BUSINESS_TZ).startOf('month').setZone(OFFICE_TZ).toISO();
break;
case 'day':
default:
// Group by BUSINESS date (Chicago calendar day = 1am-ET day rule).
groupKey = instant.setZone(BUSINESS_TZ).startOf('day').setZone(OFFICE_TZ).toISO();
}
const existingGroup = groupedData.get(groupKey) || { datetime: groupKey, count: 0, value: 0 };
existingGroup.count++;
if (property) {
const props = event.attributes?.event_properties || event.attributes?.properties || {};
const value =
property === '$value' ? Number(event.attributes?.value || 0) : Number(props[property] || 0);
existingGroup.value = (existingGroup.value || 0) + value;
}
groupedData.set(groupKey, existingGroup);
}
return Array.from(groupedData.values()).sort(
(a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime)
);
}
}
+3
View File
@@ -15,5 +15,8 @@ export function createPool(envPrefix = 'DB', overrides = {}) {
max: overrides.max ?? 20,
idleTimeoutMillis: overrides.idleTimeoutMillis ?? 30_000,
connectionTimeoutMillis: overrides.connectionTimeoutMillis ?? 5_000,
// Pin the session timezone to business time (see docs/TIME.md) so date
// bucketing can't silently revert if the database default changes.
options: overrides.options ?? '-c TimeZone=America/Chicago',
});
}
+5 -3
View File
@@ -14,7 +14,8 @@
"./logging": "./logging/index.js",
"./errors/handler": "./errors/handler.js",
"./cors/policy": "./cors/policy.js",
"./rate-limit/login": "./rate-limit/login.js"
"./rate-limit/login": "./rate-limit/login.js",
"./business-time": "./business-time/index.js"
},
"dependencies": {
"cors": "^2.8.5",
@@ -23,6 +24,7 @@
"jsonwebtoken": "^9.0.2",
"pg": "^8.11.3",
"pino": "^9.5.0",
"pino-http": "^10.3.0"
"pino-http": "^10.3.0",
"luxon": "^3.7.2"
}
}
}
+11 -12
View File
@@ -1,4 +1,5 @@
import express from 'express';
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router();
// Forecasting: summarize sales for products received in a period by brand
@@ -15,13 +16,12 @@ router.get('/forecast', async (req, res) => {
return res.status(400).json({ error: 'Missing required parameter: brand' });
}
// Default to last 30 days if no dates provided
const endDate = endDateStr ? new Date(endDateStr) : new Date();
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
// Normalize to date boundaries for consistency
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
// Default to last 30 business days if no dates provided. Params are
// date-only business dates ('YYYY-MM-DD'); instants are converted.
const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
const startISO = startDateStr
? businessDateOf(startDateStr)
: new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const sql = `
WITH params AS (
@@ -177,11 +177,10 @@ router.get('/forecast-v2', async (req, res) => {
return res.status(400).json({ error: 'Missing required parameter: brand' });
}
const endDate = endDateStr ? new Date(endDateStr) : new Date();
const startDate = startDateStr ? new Date(startDateStr) : new Date(endDate.getTime() - 29 * 24 * 60 * 60 * 1000);
const startISO = new Date(Date.UTC(startDate.getUTCFullYear(), startDate.getUTCMonth(), startDate.getUTCDate())).toISOString();
const endISO = new Date(Date.UTC(endDate.getUTCFullYear(), endDate.getUTCMonth(), endDate.getUTCDate())).toISOString();
const endISO = endDateStr ? businessDateOf(endDateStr) : businessTodayStr();
const startISO = startDateStr
? businessDateOf(startDateStr)
: new Date(Date.parse(endISO) - 29 * 24 * 60 * 60 * 1000).toISOString().slice(0, 10);
const sql = `
WITH params AS (
+29 -44
View File
@@ -1,5 +1,6 @@
import express from 'express';
import db from '../utils/db.js';
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router();
// Helper function to execute queries using the connection pool
@@ -306,15 +307,13 @@ router.get('/replenishment/metrics', async (req, res) => {
// Reads from product_forecasts table (lifecycle-aware forecasting pipeline).
// Falls back to velocity-based projection if forecast table is empty.
router.get('/forecast/metrics', async (req, res) => {
const today = new Date();
const thirtyDaysOut = new Date(today);
thirtyDaysOut.setDate(today.getDate() + 30);
const startDate = req.query.startDate ? new Date(req.query.startDate) : today;
const endDate = req.query.endDate ? new Date(req.query.endDate) : thirtyDaysOut;
const startISO = startDate.toISOString().split('T')[0];
const endISO = endDate.toISOString().split('T')[0];
const days = Math.max(1, Math.round((endDate - startDate) / (1000 * 60 * 60 * 24)));
// Business dates (Chicago calendar — see docs/TIME.md). Inputs may be
// date-only strings or full instants.
const startISO = req.query.startDate ? businessDateOf(req.query.startDate) : businessTodayStr();
const endISO = req.query.endDate
? businessDateOf(req.query.endDate)
: businessNow().plus({ days: 30 }).toISODate();
const days = Math.max(1, Math.round((Date.parse(endISO) - Date.parse(startISO)) / (1000 * 60 * 60 * 24)));
try {
// Check if product_forecasts has data
@@ -331,9 +330,7 @@ router.get('/forecast/metrics', async (req, res) => {
const { rows: [horizonRow] } = await executeQuery(
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
);
const forecastHorizonISO = horizonRow.max_date instanceof Date
? horizonRow.max_date.toISOString().split('T')[0]
: horizonRow.max_date;
const forecastHorizonISO = formatDateCol(horizonRow.max_date);
const forecastHorizon = new Date(forecastHorizonISO + 'T00:00:00');
const clampedEndISO = endISO <= forecastHorizonISO ? endISO : forecastHorizonISO;
const needsExtrapolation = endISO > forecastHorizonISO;
@@ -376,7 +373,7 @@ router.get('/forecast/metrics', async (req, res) => {
`, [startISO, clampedEndISO]);
const dailyForecasts = dailyRows.map(d => ({
date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date,
date: formatDateCol(d.date),
units: parseFloat(d.units) || 0,
revenue: parseFloat(d.revenue) || 0,
confidence: confidenceLevel,
@@ -475,7 +472,7 @@ router.get('/forecast/metrics', async (req, res) => {
const estUnits = (baselineDaily * seasonal) / avgPrice + newProductDailyUnits;
dailyForecasts.push({
date: d.toISOString().split('T')[0],
date: formatDateCol(d),
units: parseFloat(estUnits.toFixed(1)),
revenue: parseFloat(estRevenue.toFixed(2)),
confidence: 0, // lower confidence for extrapolated data
@@ -539,7 +536,7 @@ router.get('/forecast/metrics', async (req, res) => {
`, [startISO, clampedEndISO]);
const dailyForecastsByPhase = dailyPhaseRows.map(d => ({
date: d.date instanceof Date ? d.date.toISOString().split('T')[0] : d.date,
date: formatDateCol(d.date),
preorder: parseFloat(d.preorder) || 0,
launch: parseFloat(d.launch) || 0,
decay: parseFloat(d.decay) || 0,
@@ -597,11 +594,12 @@ router.get('/forecast/metrics', async (req, res) => {
const dailyRevenue = parseFloat(totals.daily_revenue) || 0;
const dailyForecasts = [];
const fallbackStart = new Date(startISO + 'T00:00:00'); // local midnight — round-trips via formatDateCol
for (let i = 0; i < days; i++) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + i);
const d = new Date(fallbackStart);
d.setDate(fallbackStart.getDate() + i);
dailyForecasts.push({
date: d.toISOString().split('T')[0],
date: formatDateCol(d),
units: parseFloat(dailyUnits.toFixed(1)),
revenue: parseFloat(dailyRevenue.toFixed(2)),
confidence: 0,
@@ -776,7 +774,7 @@ router.get('/forecast/accuracy', async (req, res) => {
`);
const accuracyTrend = trendRows.map(r => ({
date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
date: formatDateCol(r.run_date),
mae: r.mae != null ? parseFloat(parseFloat(r.mae).toFixed(4)) : null,
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
bias: r.bias != null ? parseFloat(parseFloat(r.bias).toFixed(4)) : null,
@@ -796,7 +794,7 @@ router.get('/forecast/accuracy', async (req, res) => {
`);
const accuracyTrendWeekly = weeklyTrendRows.map(r => ({
date: r.run_date instanceof Date ? r.run_date.toISOString().split('T')[0] : r.run_date,
date: formatDateCol(r.run_date),
wmape: r.wmape != null ? parseFloat((parseFloat(r.wmape) * 100).toFixed(1)) : null,
naiveWmape: r.naive_wmape != null ? parseFloat((parseFloat(r.naive_wmape) * 100).toFixed(1)) : null,
fva: r.fva != null ? parseFloat(parseFloat(r.fva).toFixed(3)) : null,
@@ -808,12 +806,8 @@ router.get('/forecast/accuracy', async (req, res) => {
computedAt,
daysOfHistory: parseInt(historyInfo.days_of_history) || 0,
historyRange: {
from: historyInfo.earliest_date instanceof Date
? historyInfo.earliest_date.toISOString().split('T')[0]
: historyInfo.earliest_date,
to: historyInfo.latest_date instanceof Date
? historyInfo.latest_date.toISOString().split('T')[0]
: historyInfo.latest_date,
from: formatDateCol(historyInfo.earliest_date),
to: formatDateCol(historyInfo.latest_date),
},
overall: shapeOverall(overall),
overallInclDormant: shapeOverall(overallInclDormant),
@@ -1038,10 +1032,9 @@ router.get('/best-sellers', async (req, res) => {
// GET /dashboard/year-revenue-estimate
// Returns YTD actual revenue + rest-of-year forecast revenue for a full-year estimate
router.get('/year-revenue-estimate', async (req, res) => {
const now = new Date();
const yearStart = `${now.getFullYear()}-01-01`;
const todayISO = now.toISOString().split('T')[0];
const yearEndISO = `${now.getFullYear()}-12-31`;
const todayISO = businessTodayStr(); // business 'today', not UTC
const yearStart = `${todayISO.slice(0, 4)}-01-01`;
const yearEndISO = `${todayISO.slice(0, 4)}-12-31`;
try {
// YTD actual revenue from orders
@@ -1055,11 +1048,7 @@ router.get('/year-revenue-estimate', async (req, res) => {
const { rows: [horizonRow] } = await executeQuery(
`SELECT MAX(forecast_date) AS max_date FROM product_forecasts`
);
const forecastHorizonISO = horizonRow?.max_date
? (horizonRow.max_date instanceof Date
? horizonRow.max_date.toISOString().split('T')[0]
: horizonRow.max_date)
: todayISO;
const forecastHorizonISO = horizonRow?.max_date ? formatDateCol(horizonRow.max_date) : todayISO;
const clampedEnd = yearEndISO <= forecastHorizonISO ? yearEndISO : forecastHorizonISO;
@@ -1111,13 +1100,9 @@ router.get('/year-revenue-estimate', async (req, res) => {
// GET /dashboard/sales/metrics
// Returns sales metrics for specified period
router.get('/sales/metrics', async (req, res) => {
// Default to last 30 days if no date range provided
const today = new Date();
const thirtyDaysAgo = new Date(today);
thirtyDaysAgo.setDate(today.getDate() - 30);
const startDate = req.query.startDate || thirtyDaysAgo.toISOString();
const endDate = req.query.endDate || today.toISOString();
// Default: trailing 30 business days ending now (Chicago day start).
const startDate = req.query.startDate || businessNow().minus({ days: 30 }).startOf('day').toISO();
const endDate = req.query.endDate || new Date().toISOString();
try {
// Get daily orders and totals for the specified period
@@ -1187,13 +1172,13 @@ router.get('/sales/metrics', async (req, res) => {
totalCogs: parseFloat(metrics?.total_cogs) || 0,
totalRevenue: parseFloat(metrics?.total_revenue) || 0,
dailySales: dailyRows.map(day => ({
date: day.sale_date,
date: formatDateCol(day.sale_date),
units: parseInt(day.total_units) || 0,
revenue: parseFloat(day.total_revenue) || 0,
cogs: parseFloat(day.total_cogs) || 0
})),
dailySalesByPhase: dailyPhaseRows.map(d => ({
date: d.sale_date,
date: formatDateCol(d.sale_date),
preorder: parseFloat(d.preorder) || 0,
launch: parseFloat(d.launch) || 0,
decay: parseFloat(d.decay) || 0,
+4 -1
View File
@@ -676,7 +676,10 @@ 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 store Central wall-clock literals — return them as
// strings; parse with shared/business-time parseMySql() if instants are
// ever needed (see docs/TIME.md).
dateStrings: true
};
return new Promise((resolve, reject) => {
+8 -6
View File
@@ -1,4 +1,5 @@
import express from 'express';
import { businessTodayStr, businessDateOf, businessNow, formatDateCol } from '../../shared/business-time/index.js';
const router = express.Router();
// Get all orders with pagination, filtering, and sorting
@@ -10,8 +11,9 @@ router.get('/', async (req, res) => {
const offset = (page - 1) * limit;
const search = req.query.search || '';
const status = req.query.status || 'all';
const fromDate = req.query.fromDate ? new Date(req.query.fromDate) : null;
const toDate = req.query.toDate ? new Date(req.query.toDate) : null;
// Date filters are BUSINESS dates ('YYYY-MM-DD', Chicago calendar).
const fromDate = req.query.fromDate ? businessDateOf(req.query.fromDate) : null;
const toDate = req.query.toDate ? businessDateOf(req.query.toDate) : null;
const minAmount = parseFloat(req.query.minAmount) || 0;
const maxAmount = req.query.maxAmount ? parseFloat(req.query.maxAmount) : null;
const sortColumn = req.query.sortColumn || 'date';
@@ -35,14 +37,14 @@ router.get('/', async (req, res) => {
}
if (fromDate) {
conditions.push(`DATE(o1.date) >= DATE($${paramCounter})`);
params.push(fromDate.toISOString());
conditions.push(`DATE(o1.date) >= $${paramCounter}::date`);
params.push(fromDate);
paramCounter++;
}
if (toDate) {
conditions.push(`DATE(o1.date) <= DATE($${paramCounter})`);
params.push(toDate.toISOString());
conditions.push(`DATE(o1.date) <= $${paramCounter}::date`);
params.push(toDate);
paramCounter++;
}
+2 -1
View File
@@ -1,4 +1,5 @@
import express from 'express';
import { formatDateCol } from '../../shared/business-time/index.js';
import { PurchaseOrderStatus, ReceivingStatus } from '../types/status-codes.js';
const router = express.Router();
@@ -1003,7 +1004,7 @@ router.get('/:id/forecast', async (req, res) => {
phase,
method,
forecast: rows.map(r => ({
date: r.date instanceof Date ? r.date.toISOString().split('T')[0] : r.date,
date: formatDateCol(r.date),
units: parseFloat(r.units) || 0,
revenue: parseFloat(r.revenue) || 0,
confidenceLower: parseFloat(r.confidence_lower) || 0,
@@ -1,4 +1,5 @@
import express from 'express';
import { businessNow } from '../../shared/business-time/index.js';
const router = express.Router();
// Status code constants
@@ -716,7 +717,7 @@ router.get('/category-analysis', async (req, res) => {
const pool = req.app.locals.pool;
// Allow an optional "since" parameter or default to 1 year ago
const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const since = req.query.since || businessNow().minus({ days: 365 }).toISODate();
const { rows: analysis } = await pool.query(`
WITH receiving_costs AS (
@@ -769,7 +770,7 @@ router.get('/vendor-analysis', async (req, res) => {
const pool = req.app.locals.pool;
// Allow an optional "since" parameter or default to 1 year ago
const since = req.query.since || new Date(Date.now() - 365 * 24 * 60 * 60 * 1000).toISOString().split('T')[0];
const since = req.query.since || businessNow().minus({ days: 365 }).toISODate();
const { rows: metrics } = await pool.query(`
WITH receiving_data AS (
+7 -1
View File
@@ -5,7 +5,13 @@ const { Pool } = pg;
let pool;
export function initPool(config) {
pool = new Pool(config);
pool = new Pool({
// Pin the session timezone to business time (matches the
// `ALTER DATABASE inventory_db SET timezone = 'America/Chicago'` default)
// so a DB restore/migration can't silently revert date semantics.
options: '-c TimeZone=America/Chicago',
...config,
});
return pool;
}
+4 -1
View File
@@ -97,7 +97,10 @@ async function setupSshTunnel() {
password: process.env.PROD_DB_PASSWORD,
database: process.env.PROD_DB_NAME,
port: Number(process.env.PROD_DB_PORT) || 3306,
timezone: 'Z',
// DATETIME columns store Central wall-clock literals — return them as
// strings; parse with shared/business-time parseMySql() if instants are
// ever needed (see docs/TIME.md).
dateStrings: true,
};
return new Promise((resolve, reject) => {