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
+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) => {