Time unification
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user