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
@@ -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) {