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
@@ -0,0 +1,562 @@
// Shared business-time module — the single source of truth for the time
// convention (see docs/TIME.md and TIME_UNIFICATION_PLAN.md).
//
// THE CONVENTION: a business day runs 1:00am Eastern → 1:00am Eastern the next
// day. Because 1am Eastern == midnight Central year-round (both observe US
// DST), this is identical to: business date = the America/Chicago calendar
// date. All boundary math here therefore uses Chicago days; Eastern exists
// only for display formatting.
//
// MySQL DATETIME columns on 192.168.1.5 store Central wall-clock literals, so
// toMySqlBound()/parseMySql() convert to/from Chicago wall-clock strings with
// no hour shift. Never hand-roll an hour offset and never use a fixed
// 'UTC-05:00' zone (wrong every winter).
import { DateTime } from 'luxon';
export const BUSINESS_TZ = 'America/Chicago'; // canonical
export const OFFICE_TZ = 'America/New_York'; // display only
const MYSQL_DATETIME_FORMAT = 'yyyy-LL-dd HH:mm:ss';
const DATE_ONLY_RE = /^\d{4}-\d{2}-\d{2}$/;
// ---------------------------------------------------------------------------
// Parsing / coercion
// ---------------------------------------------------------------------------
/**
* Coerce any supported input (DateTime, Date, millis, ISO string, SQL string)
* to a luxon DateTime in the business zone. Date-only strings are interpreted
* as business dates (Chicago midnight). Zoned ISO strings keep their instant.
* Returns null for unparseable input.
*/
export function toBusinessTime(value) {
if (value == null || value === '') return null;
if (DateTime.isDateTime(value)) return value.setZone(BUSINESS_TZ);
if (value instanceof Date) return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
if (typeof value === 'number') return DateTime.fromMillis(value).setZone(BUSINESS_TZ);
if (typeof value === 'string') {
let dt = DateTime.fromISO(value, { zone: BUSINESS_TZ, setZone: false });
if (!dt.isValid) dt = DateTime.fromSQL(value, { zone: BUSINESS_TZ });
return dt.isValid ? dt : null;
}
return null;
}
/** True if the input is a date-only string ('YYYY-MM-DD'). */
export const isDateOnly = (value) => typeof value === 'string' && DATE_ONLY_RE.test(value);
/**
* Parse a MySQL DATETIME/TIMESTAMP literal (Central wall-clock) into a
* DateTime in the business zone. Returns null for empty / zero dates.
*/
export function parseMySql(value) {
if (!value || value === '0000-00-00 00:00:00' || value === '0000-00-00') return null;
if (value instanceof Date) {
// Defensive: a driver not configured with dateStrings handed us a Date.
// Its instant depends on that driver's timezone config — pass it through
// as an instant rather than guessing.
return DateTime.fromJSDate(value).setZone(BUSINESS_TZ);
}
const dt = DateTime.fromSQL(String(value), { zone: BUSINESS_TZ });
return dt.isValid ? dt : null;
}
/**
* Serialize an instant to a MySQL bound: Central wall-clock
* 'YYYY-MM-DD HH:mm:ss'. Use with half-open intervals: col >= ? AND col < ?.
*/
export function toMySqlBound(value) {
const dt = toBusinessTime(value);
if (!dt) throw new Error('toMySqlBound: invalid datetime input');
return dt.toFormat(MYSQL_DATETIME_FORMAT);
}
// ---------------------------------------------------------------------------
// "Today" and day boundaries
// ---------------------------------------------------------------------------
/** Now, as a DateTime in the business zone. */
export const businessNow = () => DateTime.now().setZone(BUSINESS_TZ);
/** Start of the current business day (Chicago midnight = 1am Eastern). */
export const businessToday = () => businessNow().startOf('day');
/** The current business date as 'YYYY-MM-DD'. The only sanctioned "today". */
export const businessTodayStr = () => businessNow().toISODate();
/** Start of the business day containing the given instant/date. */
export function businessDayStart(value = businessNow()) {
const dt = toBusinessTime(value);
if (!dt) throw new Error('businessDayStart: invalid datetime input');
return dt.startOf('day');
}
/** Half-open range [start, nextStart) of the business day containing value. */
export function businessDayRange(value = businessNow()) {
const start = businessDayStart(value);
return { start, end: start.plus({ days: 1 }) };
}
/** Business date ('YYYY-MM-DD') of an instant. */
export function businessDateOf(value) {
const dt = toBusinessTime(value);
return dt ? dt.toISODate() : null;
}
/**
* Format a node-postgres DATE-column value as 'YYYY-MM-DD'.
*
* node-postgres materializes DATE columns as a JS Date at process-local
* midnight, so formatting in LOCAL time round-trips to the same calendar date
* regardless of the process zone. (The old .toISOString().split('T')[0] idiom
* formatted in UTC, which emitted the PREVIOUS day for any zone east of UTC.)
* Strings (e.g. from ::text casts) pass through.
*/
export function formatDateCol(value) {
if (value == null) return null;
if (typeof value === 'string') return value.slice(0, 10);
if (value instanceof Date) {
const y = value.getFullYear();
const m = String(value.getMonth() + 1).padStart(2, '0');
const d = String(value.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
return businessDateOf(value);
}
/** Format an instant in office (Eastern) time for display. */
export function formatOffice(value, fmt = 'LLL d, yyyy h:mm a') {
const dt = toBusinessTime(value);
return dt ? dt.setZone(OFFICE_TZ).toFormat(fmt) : '';
}
// ---------------------------------------------------------------------------
// Named range presets — ONE vocabulary for every server
// ---------------------------------------------------------------------------
export const RANGE_PRESETS = [
'today',
'yesterday',
'twoDaysAgo',
'thisWeek',
'lastWeek',
'thisMonth',
'lastMonth',
'last7days',
'last30days',
'last90days',
'previous7days',
'previous30days',
'previous90days',
'custom',
];
export const isKnownRange = (name) => RANGE_PRESETS.includes(name);
// Weeks start on Sunday (business convention).
function weekStart(dt) {
const day = dt.startOf('day');
// luxon weekday: 1 = Monday … 7 = Sunday
return day.weekday === 7 ? day : day.minus({ days: day.weekday });
}
/**
* Resolve a named preset to a half-open range { start, end } of business-zone
* DateTimes (end is EXCLUSIVE — the start of the day after the last included
* business day). Throws on unknown names; 'custom' must be resolved by the
* caller via customRange().
*/
export function rangeForPreset(name, now = businessNow()) {
const current = toBusinessTime(now);
if (!current) throw new Error('rangeForPreset: invalid reference time');
const todayStart = current.startOf('day');
const tomorrowStart = todayStart.plus({ days: 1 });
switch (name) {
case 'today':
return { start: todayStart, end: tomorrowStart };
case 'yesterday':
return { start: todayStart.minus({ days: 1 }), end: todayStart };
case 'twoDaysAgo':
return { start: todayStart.minus({ days: 2 }), end: todayStart.minus({ days: 1 }) };
case 'thisWeek':
return { start: weekStart(current), end: tomorrowStart };
case 'lastWeek': {
const start = weekStart(current).minus({ weeks: 1 });
return { start, end: start.plus({ weeks: 1 }) };
}
case 'thisMonth':
return { start: todayStart.startOf('month'), end: tomorrowStart };
case 'lastMonth': {
const start = todayStart.startOf('month').minus({ months: 1 });
return { start, end: start.plus({ months: 1 }) };
}
case 'last7days':
return { start: todayStart.minus({ days: 6 }), end: tomorrowStart };
case 'last30days':
return { start: todayStart.minus({ days: 29 }), end: tomorrowStart };
case 'last90days':
return { start: todayStart.minus({ days: 89 }), end: tomorrowStart };
case 'previous7days': {
const currentStart = todayStart.minus({ days: 6 });
return { start: currentStart.minus({ days: 7 }), end: currentStart };
}
case 'previous30days': {
const currentStart = todayStart.minus({ days: 29 });
return { start: currentStart.minus({ days: 30 }), end: currentStart };
}
case 'previous90days': {
const currentStart = todayStart.minus({ days: 89 });
return { start: currentStart.minus({ days: 90 }), end: currentStart };
}
default:
throw new Error(`Unknown time range: ${name}`);
}
}
/**
* Resolve a custom range to half-open { start, end }. Date-only strings are
* business dates: start = that Chicago midnight, end = the day AFTER the end
* date (so both endpoints are included as full business days). Zoned ISO
* instants are used as-is (end treated as exclusive).
*/
export function customRange(startInput, endInput) {
if (!startInput || !endInput) throw new Error('customRange: both bounds required');
const start = isDateOnly(startInput)
? DateTime.fromISO(startInput, { zone: BUSINESS_TZ }).startOf('day')
: toBusinessTime(startInput);
let end = isDateOnly(endInput)
? DateTime.fromISO(endInput, { zone: BUSINESS_TZ }).startOf('day').plus({ days: 1 })
: toBusinessTime(endInput);
if (!start || !start.isValid || !end || !end.isValid) {
throw new Error('customRange: invalid bounds');
}
if (end < start) throw new Error('customRange: end before start');
return { start, end };
}
/**
* Previous comparable range. Accepts a preset name (uses the conventional
* predecessor: today→yesterday, thisWeek→lastWeek, …) or a { start, end }
* half-open range (shifts back by its own duration).
*/
export function previousRange(nameOrRange, now = businessNow()) {
if (typeof nameOrRange === 'string') {
const map = {
today: 'yesterday',
yesterday: 'twoDaysAgo',
thisWeek: 'lastWeek',
thisMonth: 'lastMonth',
last7days: 'previous7days',
last30days: 'previous30days',
last90days: 'previous90days',
};
const prev = map[nameOrRange];
if (prev) return rangeForPreset(prev, now);
// Calendar-unit presets without a named predecessor.
if (nameOrRange === 'twoDaysAgo') {
const { start } = rangeForPreset('twoDaysAgo', now);
return { start: start.minus({ days: 1 }), end: start };
}
if (nameOrRange === 'lastWeek') {
const { start } = rangeForPreset('lastWeek', now);
return { start: start.minus({ weeks: 1 }), end: start };
}
if (nameOrRange === 'lastMonth') {
const { start } = rangeForPreset('lastMonth', now);
return { start: start.minus({ months: 1 }), end: start };
}
return null;
}
const { start, end } = nameOrRange || {};
if (!start || !end) return null;
const duration = end.diff(start);
return { start: start.minus(duration), end: start };
}
// ---------------------------------------------------------------------------
// MySQL query helpers (acot-server style)
// ---------------------------------------------------------------------------
const RANGE_LABELS = {
today: 'Today',
yesterday: 'Yesterday',
twoDaysAgo: 'Two Days Ago',
thisWeek: 'This Week',
lastWeek: 'Last Week',
thisMonth: 'This Month',
lastMonth: 'Last Month',
last7days: 'Last 7 Days',
last30days: 'Last 30 Days',
last90days: 'Last 90 Days',
previous7days: 'Previous 7 Days',
previous30days: 'Previous 30 Days',
previous90days: 'Previous 90 Days',
};
export const getTimeRangeLabel = (timeRange) => RANGE_LABELS[timeRange] || timeRange;
/** Eastern display date for a range label ('Jun 12, 2026'). */
export function formatBusinessDate(value) {
const dt = toBusinessTime(value);
return dt ? dt.setZone(OFFICE_TZ).toFormat('LLL d, yyyy') : '';
}
/**
* Build a half-open MySQL WHERE fragment + Central wall-clock bound params
* for a named or custom range. The where clause repeats the column for both
* bounds so callers re-targeting another column MUST replace globally
* (/date_placed/g).
*/
export function getTimeRangeConditions(timeRange, startDate, endDate) {
let range;
let label;
if (timeRange === 'custom' && startDate && endDate) {
range = customRange(startDate, endDate);
// Display the last *included* business day, not the exclusive endpoint.
label = `${formatBusinessDate(range.start)} - ${formatBusinessDate(range.end.minus({ milliseconds: 1 }))}`;
} else {
const normalized = timeRange || 'today';
range = rangeForPreset(normalized);
label = getTimeRangeLabel(normalized);
}
return {
whereClause: 'date_placed >= ? AND date_placed < ?',
params: [toMySqlBound(range.start), toMySqlBound(range.end)],
range,
start: range.start.toJSDate(),
end: range.end.toJSDate(), // EXCLUSIVE
dateRange: {
start: range.start.toUTC().toISO(),
end: range.end.toUTC().toISO(), // EXCLUSIVE
label,
},
};
}
/** Half-open JS Date bounds for a named range (end exclusive). */
export function getBusinessDayBounds(timeRange) {
const range = rangeForPreset(timeRange);
return { start: range.start.toJSDate(), end: range.end.toJSDate() };
}
/** Parse a MySQL datetime literal to a JS Date (legacy convenience). */
export function parseBusinessDate(mysqlDatetime) {
const dt = parseMySql(mysqlDatetime);
return dt ? dt.toUTC().toJSDate() : null;
}
/** Serialize any instant to a MySQL Central wall-clock string, null-safe. */
export function formatMySQLDate(value) {
const dt = toBusinessTime(value);
return dt ? dt.toFormat(MYSQL_DATETIME_FORMAT) : null;
}
// Legacy helpers kept for consumers that need raw day/week math. getDayEnd
// returns the half-open endpoint (next day start); prefer businessDayRange.
export const _internal = {
getDayStart: (input) => businessDayStart(input ?? businessNow()),
getDayEnd: (input) => businessDayStart(input ?? businessNow()).plus({ days: 1 }),
getWeekStart: (input) => weekStart(toBusinessTime(input ?? businessNow())),
getRangeForTimeRange: (timeRange, now) => rangeForPreset(timeRange, now),
BUSINESS_DAY_START_HOUR: 1, // 1am Eastern == midnight Central
};
// ---------------------------------------------------------------------------
// TimeManager — compatibility class for the dashboard/klaviyo tree.
//
// Same method surface as the old dashboard/utils/time.utils.js, with the
// defects fixed:
// - getDayEnd: was next-start minus 1 MINUTE (a 59.999s hole every day);
// now next-start minus 1 millisecond. Kept inclusive (rather than
// half-open) because consumers iterate `while (day <= end)` — a half-open
// end would add a spurious trailing bucket. Klaviyo API bounds use
// less-than(end), so the residual 1ms hole is negligible.
// - groupEventsByInterval: grouped in the event string's own zone; now
// groups days by business date and hour/week/month in office time.
// ---------------------------------------------------------------------------
export class TimeManager {
constructor(dayStartHour = 1) {
this.timezone = OFFICE_TZ;
this.dayStartHour = dayStartHour; // retained for API compat; 1 == Chicago midnight
this.weekStartDay = 7; // Sunday
}
getNow() {
return DateTime.now().setZone(this.timezone);
}
toDateTime(date) {
if (!date) return null;
if (DateTime.isDateTime(date)) return date.setZone(this.timezone);
const dt = DateTime.fromISO(date instanceof Date ? date.toISOString() : date);
if (!dt.isValid) {
console.error('[TimeManager] Invalid date input:', date);
return null;
}
return dt.setZone(this.timezone);
}
getDayStart(dt = this.getNow()) {
if (!dt.isValid) return this.getNow();
return businessDayStart(dt).setZone(this.timezone);
}
getDayEnd(dt = this.getNow()) {
if (!dt.isValid) return this.getNow();
return businessDayStart(dt).plus({ days: 1 }).minus({ milliseconds: 1 }).setZone(this.timezone);
}
getWeekStart(dt = this.getNow()) {
if (!dt.isValid) return this.getNow();
return weekStart(toBusinessTime(dt)).setZone(this.timezone);
}
formatForAPI(date) {
const dt = this.toDateTime(date);
if (!dt || !dt.isValid) {
console.error('[TimeManager] Invalid date for API:', date);
return null;
}
return dt.toUTC().toISO();
}
formatForDisplay(date) {
const dt = this.toDateTime(date);
if (!dt || !dt.isValid) return '';
return dt.toFormat('LLL d, yyyy h:mm a');
}
isValidDateRange(start, end) {
const startDt = this.toDateTime(start);
const endDt = this.toDateTime(end);
return startDt && endDt && endDt > startDt;
}
getLastNHours(hours) {
const now = this.getNow();
return { start: now.minus({ hours }), end: now };
}
getLastNDays(days) {
const now = this.getNow();
return { start: this.getDayStart(now).minus({ days }), end: this.getDayEnd(now) };
}
getDateRange(period) {
if (period === 'custom') {
console.warn('[TimeManager] Custom ranges should use getCustomRange method');
return null;
}
const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
try {
const { start, end } = rangeForPreset(normalized);
return {
start: start.setZone(this.timezone),
end: end.minus({ milliseconds: 1 }).setZone(this.timezone),
};
} catch {
console.warn(`[TimeManager] Unknown period: ${period}`);
return null;
}
}
getCustomRange(startDate, endDate) {
if (!startDate || !endDate) {
console.error('[TimeManager] Custom range requires both start and end dates');
return null;
}
try {
const { start, end } = customRange(startDate, endDate);
const inclusiveEnd = isDateOnly(endDate) ? end.minus({ milliseconds: 1 }) : end;
return { start: start.setZone(this.timezone), end: inclusiveEnd.setZone(this.timezone) };
} catch (error) {
console.error('[TimeManager] Invalid dates provided for custom range:', error.message);
return null;
}
}
getPreviousPeriod(period, now = this.getNow()) {
const normalized = period.startsWith('previous') ? period.replace('previous', 'last') : period;
const current = (() => {
try {
return rangeForPreset(normalized, now);
} catch {
return null;
}
})();
if (!current) {
console.warn(`[TimeManager] No previous period defined for: ${period}`);
return null;
}
const prev =
previousRange(normalized, now) ||
previousRange(current, now);
if (!prev) return null;
return {
start: prev.start.setZone(this.timezone),
end: prev.end.minus({ milliseconds: 1 }).setZone(this.timezone),
};
}
formatDuration(ms) {
return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
}
getRelativeTime(date) {
const dt = this.toDateTime(date);
if (!dt) return '';
return dt.toRelative();
}
groupEventsByInterval(events, interval = 'day', property = null) {
if (!events?.length) return [];
const groupedData = new Map();
for (const event of events) {
const instant = DateTime.fromISO(event.attributes.datetime, { setZone: true });
if (!instant.isValid) continue;
let groupKey;
switch (interval) {
case 'hour':
groupKey = instant.setZone(OFFICE_TZ).startOf('hour').toISO();
break;
case 'week':
groupKey = weekStart(instant.setZone(BUSINESS_TZ)).setZone(OFFICE_TZ).toISO();
break;
case 'month':
groupKey = instant.setZone(BUSINESS_TZ).startOf('month').setZone(OFFICE_TZ).toISO();
break;
case 'day':
default:
// Group by BUSINESS date (Chicago calendar day = 1am-ET day rule).
groupKey = instant.setZone(BUSINESS_TZ).startOf('day').setZone(OFFICE_TZ).toISO();
}
const existingGroup = groupedData.get(groupKey) || { datetime: groupKey, count: 0, value: 0 };
existingGroup.count++;
if (property) {
const props = event.attributes?.event_properties || event.attributes?.properties || {};
const value =
property === '$value' ? Number(event.attributes?.value || 0) : Number(props[property] || 0);
existingGroup.value = (existingGroup.value || 0) + value;
}
groupedData.set(groupKey, existingGroup);
}
return Array.from(groupedData.values()).sort(
(a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime)
);
}
}