// 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) ); } }