Fix time periods on financial overview, remove some logging

This commit is contained in:
2025-09-21 23:47:05 -04:00
parent 5d46a2a7e5
commit 2ff325a132
18 changed files with 426 additions and 445 deletions

View File

@@ -12,6 +12,7 @@
"cors": "^2.8.5",
"dotenv": "^16.3.1",
"express": "^4.18.2",
"luxon": "^3.5.0",
"morgan": "^1.10.0",
"mysql2": "^3.6.5",
"ssh2": "^1.14.0"
@@ -826,6 +827,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/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",

View File

@@ -14,9 +14,10 @@
"morgan": "^1.10.0",
"ssh2": "^1.14.0",
"mysql2": "^3.6.5",
"compression": "^1.7.4"
"compression": "^1.7.4",
"luxon": "^3.5.0"
},
"devDependencies": {
"nodemon": "^3.0.1"
}
}
}

View File

@@ -1,7 +1,17 @@
const express = require('express');
const { DateTime } = require('luxon');
const router = express.Router();
const { getDbConnection, getPoolStatus } = require('../db/connection');
const { getTimeRangeConditions, formatBusinessDate, getBusinessDayBounds } = require('../utils/timeUtils');
const {
getTimeRangeConditions,
formatBusinessDate,
getBusinessDayBounds,
_internal: timeHelpers
} = require('../utils/timeUtils');
const TIMEZONE = 'America/New_York';
const BUSINESS_DAY_START_HOUR = timeHelpers?.BUSINESS_DAY_START_HOUR ?? 1;
// Image URL generation utility
const getImageUrls = (pid, iid = 1) => {
@@ -421,6 +431,24 @@ router.get('/financials', async (req, res) => {
const { whereClause, params, dateRange } = getTimeRangeConditions(timeRange, startDate, endDate);
const financialWhere = whereClause.replace(/date_placed/g, 'date_change');
const formatDebugBound = (value) => {
if (!value) return 'n/a';
const parsed = DateTime.fromSQL(value, { zone: 'UTC-05:00' });
if (!parsed.isValid) {
return `invalid(${value})`;
}
return parsed.setZone(TIMEZONE).toISO();
};
console.log('[FINANCIALS] request params', {
timeRange: timeRange || 'default',
startDate,
endDate,
whereClause: financialWhere,
params,
boundsEastern: Array.isArray(params) ? params.map(formatDebugBound) : [],
});
const [totalsRows] = await connection.execute(
buildFinancialTotalsQuery(financialWhere),
params
@@ -428,6 +456,11 @@ router.get('/financials', async (req, res) => {
const totals = normalizeFinancialTotals(totalsRows[0]);
console.log('[FINANCIALS] totals query result', {
rows: totalsRows.length,
totals,
});
const [trendRows] = await connection.execute(
buildFinancialTrendQuery(financialWhere),
params
@@ -435,11 +468,25 @@ router.get('/financials', async (req, res) => {
const trend = trendRows.map(normalizeFinancialTrendRow);
console.log('[FINANCIALS] trend query result', {
rows: trendRows.length,
first: trend[0] || null,
last: trend[trend.length - 1] || null,
});
let previousTotals = null;
let comparison = null;
const previousRange = getPreviousPeriodRange(timeRange, startDate, endDate);
if (previousRange) {
console.log('[FINANCIALS] previous range params', {
timeRange: timeRange || 'default',
prevWhere: previousRange.whereClause.replace(/date_placed/g, 'date_change'),
params: previousRange.params,
boundsEastern: Array.isArray(previousRange.params)
? previousRange.params.map(formatDebugBound)
: [],
});
const prevWhere = previousRange.whereClause.replace(/date_placed/g, 'date_change');
const [previousRows] = await connection.execute(
buildFinancialTotalsQuery(prevWhere),
@@ -458,12 +505,37 @@ router.get('/financials', async (req, res) => {
};
}
const trendDebugSample = trend.slice(-3).map((item) => ({
date: item.date,
timestamp: item.timestamp,
income: item.income,
grossSales: item.grossSales,
}));
const debugInfo = {
serverTimeUtc: new Date().toISOString(),
timeRange: timeRange || 'default',
params,
boundsEastern: Array.isArray(params) ? params.map(formatDebugBound) : [],
trendCount: trend.length,
trendSample: trendDebugSample,
previousRange: previousRange
? {
params: previousRange.params,
boundsEastern: Array.isArray(previousRange.params)
? previousRange.params.map(formatDebugBound)
: [],
}
: null,
};
res.json({
dateRange,
totals,
previousTotals,
comparison,
trend,
debug: debugInfo,
});
} catch (error) {
console.error('Error in /financials:', error);
@@ -662,44 +734,35 @@ function processShippingData(shippingResult, totalShipped) {
}
function calculatePeriodProgress(timeRange) {
const now = new Date();
const easternTime = new Date(now.getTime() - (5 * 60 * 60 * 1000)); // UTC-5
switch (timeRange) {
case 'today': {
const { start } = getBusinessDayBounds('today');
const businessStart = new Date(start);
const businessEnd = new Date(businessStart);
businessEnd.setDate(businessEnd.getDate() + 1);
businessEnd.setHours(0, 59, 59, 999); // 12:59 AM next day
const elapsed = easternTime.getTime() - businessStart.getTime();
const total = businessEnd.getTime() - businessStart.getTime();
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
case 'thisWeek': {
const startOfWeek = new Date(easternTime);
startOfWeek.setDate(easternTime.getDate() - easternTime.getDay()); // Sunday
startOfWeek.setHours(1, 0, 0, 0); // 1 AM business day start
const endOfWeek = new Date(startOfWeek);
endOfWeek.setDate(endOfWeek.getDate() + 7);
const elapsed = easternTime.getTime() - startOfWeek.getTime();
const total = endOfWeek.getTime() - startOfWeek.getTime();
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
case 'thisMonth': {
const startOfMonth = new Date(easternTime.getFullYear(), easternTime.getMonth(), 1, 1, 0, 0, 0);
const endOfMonth = new Date(easternTime.getFullYear(), easternTime.getMonth() + 1, 1, 0, 59, 59, 999);
const elapsed = easternTime.getTime() - startOfMonth.getTime();
const total = endOfMonth.getTime() - startOfMonth.getTime();
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
default:
return 100;
if (!['today', 'thisWeek', 'thisMonth'].includes(timeRange)) {
return 100;
}
const now = DateTime.now().setZone(TIMEZONE);
let range;
try {
range = timeHelpers.getRangeForTimeRange(timeRange, now);
} catch (error) {
console.error(`[STATS] Failed to derive range for ${timeRange}:`, error);
return 100;
}
if (!range?.start || !range?.end) {
return 100;
}
const total = range.end.toMillis() - range.start.toMillis();
if (total <= 0) {
return 100;
}
const elapsed = Math.min(
Math.max(now.toMillis() - range.start.toMillis(), 0),
total
);
return Math.min(100, Math.max(0, (elapsed / total) * 100));
}
function buildFinancialTotalsQuery(whereClause) {
@@ -718,9 +781,13 @@ function buildFinancialTotalsQuery(whereClause) {
}
function buildFinancialTrendQuery(whereClause) {
const businessDayOffset = BUSINESS_DAY_START_HOUR;
return `
SELECT
DATE(date_change) as date,
DATE_FORMAT(
DATE_SUB(date_change, INTERVAL ${businessDayOffset} HOUR),
'%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,
@@ -730,8 +797,8 @@ function buildFinancialTrendQuery(whereClause) {
FROM report_sales_data
WHERE ${whereClause}
AND action IN (1, 2, 3)
GROUP BY DATE(date_change)
ORDER BY date ASC
GROUP BY businessDate
ORDER BY businessDate ASC
`;
}
@@ -772,15 +839,48 @@ function normalizeFinancialTrendRow(row = {}) {
const profit = income - cogs;
const margin = income !== 0 ? (profit / income) * 100 : 0;
let timestamp = null;
let dateValue = row.businessDate || row.date || null;
if (row.date instanceof Date) {
const resolveBusinessDayStart = (value) => {
if (!value) {
return null;
}
let dt;
if (value instanceof Date) {
dt = DateTime.fromJSDate(value, { zone: TIMEZONE });
} else if (typeof value === 'string') {
dt = DateTime.fromISO(value, { zone: TIMEZONE });
if (!dt.isValid) {
dt = DateTime.fromSQL(value, { zone: TIMEZONE });
}
}
if (!dt || !dt.isValid) {
return null;
}
const hour = BUSINESS_DAY_START_HOUR;
return dt.set({
hour,
minute: 0,
second: 0,
millisecond: 0,
});
};
const businessDayStart = resolveBusinessDayStart(dateValue);
if (businessDayStart) {
timestamp = businessDayStart.toUTC().toISO();
dateValue = businessDayStart.toISO();
} else if (row.date instanceof Date) {
timestamp = new Date(row.date.getTime()).toISOString();
} else if (typeof row.date === 'string') {
timestamp = new Date(`${row.date}T00:00:00Z`).toISOString();
}
return {
date: row.date,
date: dateValue,
grossSales,
refunds,
shippingFees,

View File

@@ -1,217 +1,219 @@
// Time utilities for handling business day logic and time ranges
// Business day is 1am-12:59am Eastern time (UTC-5)
const { DateTime } = require('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');
}
const getBusinessDayBounds = (timeRange) => {
const now = new Date();
const easternTime = new Date(now.getTime() - (5 * 60 * 60 * 1000)); // UTC-5
switch (timeRange) {
case 'today': {
const start = new Date(easternTime);
start.setHours(1, 0, 0, 0); // 1 AM start of business day
const end = new Date(start);
end.setDate(end.getDate() + 1);
end.setHours(0, 59, 59, 999); // 12:59 AM next day
return { start, end };
return {
start: getDayStart(current),
end: getDayEnd(current)
};
}
case 'yesterday': {
const start = new Date(easternTime);
start.setDate(start.getDate() - 1);
start.setHours(1, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
end.setHours(0, 59, 59, 999);
return { start, end };
const target = current.minus({ days: 1 });
return {
start: getDayStart(target),
end: getDayEnd(target)
};
}
case 'thisWeek': {
const start = new Date(easternTime);
start.setDate(easternTime.getDate() - easternTime.getDay()); // Sunday
start.setHours(1, 0, 0, 0);
const end = new Date(easternTime);
end.setDate(end.getDate() + 1);
end.setHours(0, 59, 59, 999);
return { start, end };
}
case 'lastWeek': {
const start = new Date(easternTime);
start.setDate(easternTime.getDate() - easternTime.getDay() - 7); // Previous Sunday
start.setHours(1, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 7);
end.setHours(0, 59, 59, 999);
return { start, end };
}
case 'thisMonth': {
const start = new Date(easternTime.getFullYear(), easternTime.getMonth(), 1, 1, 0, 0, 0);
const end = new Date(easternTime);
end.setDate(end.getDate() + 1);
end.setHours(0, 59, 59, 999);
return { start, end };
}
case 'lastMonth': {
const start = new Date(easternTime.getFullYear(), easternTime.getMonth() - 1, 1, 1, 0, 0, 0);
const end = new Date(easternTime.getFullYear(), easternTime.getMonth(), 1, 0, 59, 59, 999);
return { start, end };
}
case 'last7days': {
const end = new Date(easternTime);
end.setHours(0, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - 7);
start.setHours(1, 0, 0, 0);
return { start, end };
}
case 'last30days': {
const end = new Date(easternTime);
end.setHours(0, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - 30);
start.setHours(1, 0, 0, 0);
return { start, end };
}
case 'last90days': {
const end = new Date(easternTime);
end.setHours(0, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - 90);
start.setHours(1, 0, 0, 0);
return { start, end };
}
case 'previous7days': {
const end = new Date(easternTime);
end.setDate(end.getDate() - 1);
end.setHours(0, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - 6);
start.setHours(1, 0, 0, 0);
return { start, end };
}
case 'previous30days': {
const end = new Date(easternTime);
end.setDate(end.getDate() - 1);
end.setHours(0, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - 29);
start.setHours(1, 0, 0, 0);
return { start, end };
}
case 'previous90days': {
const end = new Date(easternTime);
end.setDate(end.getDate() - 1);
end.setHours(0, 59, 59, 999);
const start = new Date(end);
start.setDate(start.getDate() - 89);
start.setHours(1, 0, 0, 0);
return { start, end };
}
case 'twoDaysAgo': {
const start = new Date(easternTime);
start.setDate(start.getDate() - 2);
start.setHours(1, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + 1);
end.setHours(0, 59, 59, 999);
return { start, end };
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 getTimeRangeConditions = (timeRange, startDate, endDate) => {
if (timeRange === 'custom' && startDate && endDate) {
// Custom date range
const start = new Date(startDate);
const end = new Date(endDate);
// Convert to UTC-5 (Eastern time)
const startUTC5 = new Date(start.getTime() - (5 * 60 * 60 * 1000));
const endUTC5 = new Date(end.getTime() - (5 * 60 * 60 * 1000));
return {
whereClause: 'date_placed >= ? AND date_placed <= ?',
params: [
startUTC5.toISOString().slice(0, 19).replace('T', ' '),
endUTC5.toISOString().slice(0, 19).replace('T', ' ')
],
dateRange: {
start: startDate,
end: endDate,
label: `${formatBusinessDate(start)} - ${formatBusinessDate(end)}`
}
};
const toDatabaseSqlString = (dt) => {
const normalized = ensureDateTime(dt);
if (!normalized || !normalized.isValid) {
throw new Error('Invalid datetime provided for SQL conversion');
}
if (!timeRange) {
timeRange = 'today';
}
const { start, end } = getBusinessDayBounds(timeRange);
// Convert to MySQL datetime format (UTC-5)
const startStr = start.toISOString().slice(0, 19).replace('T', ' ');
const endStr = end.toISOString().slice(0, 19).replace('T', ' ');
return {
whereClause: 'date_placed >= ? AND date_placed <= ?',
params: [startStr, endStr],
dateRange: {
start: start.toISOString(),
end: end.toISOString(),
label: getTimeRangeLabel(timeRange)
}
};
const dbTime = normalized.setZone(DB_TIMEZONE, { keepLocalTime: true });
return dbTime.toFormat(DB_DATETIME_FORMAT);
};
const formatBusinessDate = (date) => {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric'
});
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',
@@ -221,32 +223,75 @@ const getTimeRangeLabel = (timeRange) => {
last90days: 'Last 90 Days',
previous7days: 'Previous 7 Days',
previous30days: 'Previous 30 Days',
previous90days: 'Previous 90 Days',
twoDaysAgo: 'Two Days Ago'
previous90days: 'Previous 90 Days'
};
return labels[timeRange] || timeRange;
};
// Helper to convert MySQL datetime to JavaScript Date
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;
}
// MySQL datetime is stored in UTC-5, so we need to add 5 hours to get UTC
const date = new Date(mysqlDatetime + ' UTC');
date.setHours(date.getHours() + 5);
return date;
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();
};
// Helper to format date for MySQL queries
const formatMySQLDate = (date) => {
if (!date) return null;
// Convert to UTC-5 for storage
const utc5Date = new Date(date.getTime() - (5 * 60 * 60 * 1000));
return utc5Date.toISOString().slice(0, 19).replace('T', ' ');
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);
};
module.exports = {
@@ -255,5 +300,13 @@ module.exports = {
formatBusinessDate,
getTimeRangeLabel,
parseBusinessDate,
formatMySQLDate
};
formatMySQLDate,
// Expose helpers for tests or advanced consumers
_internal: {
getDayStart,
getDayEnd,
getWeekStart,
getRangeForTimeRange,
BUSINESS_DAY_START_HOUR
}
};