Time unification
This commit is contained in:
@@ -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)
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -15,5 +15,8 @@ export function createPool(envPrefix = 'DB', overrides = {}) {
|
||||
max: overrides.max ?? 20,
|
||||
idleTimeoutMillis: overrides.idleTimeoutMillis ?? 30_000,
|
||||
connectionTimeoutMillis: overrides.connectionTimeoutMillis ?? 5_000,
|
||||
// Pin the session timezone to business time (see docs/TIME.md) so date
|
||||
// bucketing can't silently revert if the database default changes.
|
||||
options: overrides.options ?? '-c TimeZone=America/Chicago',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -14,7 +14,8 @@
|
||||
"./logging": "./logging/index.js",
|
||||
"./errors/handler": "./errors/handler.js",
|
||||
"./cors/policy": "./cors/policy.js",
|
||||
"./rate-limit/login": "./rate-limit/login.js"
|
||||
"./rate-limit/login": "./rate-limit/login.js",
|
||||
"./business-time": "./business-time/index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"cors": "^2.8.5",
|
||||
@@ -23,6 +24,7 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"pg": "^8.11.3",
|
||||
"pino": "^9.5.0",
|
||||
"pino-http": "^10.3.0"
|
||||
"pino-http": "^10.3.0",
|
||||
"luxon": "^3.7.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user