448 lines
13 KiB
JavaScript
448 lines
13 KiB
JavaScript
import { DateTime } from 'luxon';
|
|
|
|
export class TimeManager {
|
|
constructor(dayStartHour = 1) {
|
|
this.timezone = 'America/New_York';
|
|
this.dayStartHour = dayStartHour; // Hour (0-23) when the business day starts
|
|
this.weekStartDay = 7; // 7 = Sunday in Luxon
|
|
}
|
|
|
|
/**
|
|
* Get the start of the current business day
|
|
* If current time is before dayStartHour, return previous day at dayStartHour
|
|
*/
|
|
getDayStart(dt = this.getNow()) {
|
|
if (!dt.isValid) {
|
|
console.error("[TimeManager] Invalid datetime provided to getDayStart");
|
|
return this.getNow();
|
|
}
|
|
const dayStart = dt.set({ hour: this.dayStartHour, minute: 0, second: 0, millisecond: 0 });
|
|
return dt.hour < this.dayStartHour ? dayStart.minus({ days: 1 }) : dayStart;
|
|
}
|
|
|
|
/**
|
|
* Get the end of the current business day
|
|
* End is defined as dayStartHour - 1 minute on the next day
|
|
*/
|
|
getDayEnd(dt = this.getNow()) {
|
|
if (!dt.isValid) {
|
|
console.error("[TimeManager] Invalid datetime provided to getDayEnd");
|
|
return this.getNow();
|
|
}
|
|
const nextDay = this.getDayStart(dt).plus({ days: 1 });
|
|
return nextDay.minus({ minutes: 1 });
|
|
}
|
|
|
|
/**
|
|
* Get the start of the week containing the given date
|
|
* Aligns with custom day start time and starts on Sunday
|
|
*/
|
|
getWeekStart(dt = this.getNow()) {
|
|
if (!dt.isValid) {
|
|
console.error("[TimeManager] Invalid datetime provided to getWeekStart");
|
|
return this.getNow();
|
|
}
|
|
// Set to start of week (Sunday) and adjust hour
|
|
const weekStart = dt.set({ weekday: this.weekStartDay }).startOf('day');
|
|
// If the week start time would be after the given time, go back a week
|
|
if (weekStart > dt) {
|
|
return weekStart.minus({ weeks: 1 }).set({ hour: this.dayStartHour });
|
|
}
|
|
return weekStart.set({ hour: this.dayStartHour });
|
|
}
|
|
|
|
/**
|
|
* Convert any date input to a Luxon DateTime in Eastern time
|
|
*/
|
|
toDateTime(date) {
|
|
if (!date) return null;
|
|
|
|
if (date instanceof DateTime) {
|
|
return date.setZone(this.timezone);
|
|
}
|
|
|
|
// If it's an ISO string or Date object, parse it
|
|
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);
|
|
}
|
|
|
|
/**
|
|
* Format a date for API requests (UTC ISO string)
|
|
*/
|
|
formatForAPI(date) {
|
|
if (!date) return null;
|
|
|
|
// Parse the input date
|
|
const dt = this.toDateTime(date);
|
|
if (!dt || !dt.isValid) {
|
|
console.error("[TimeManager] Invalid date for API:", date);
|
|
return null;
|
|
}
|
|
|
|
// Convert to UTC for API request
|
|
const utc = dt.toUTC();
|
|
|
|
console.log("[TimeManager] API date conversion:", {
|
|
input: date,
|
|
eastern: dt.toISO(),
|
|
utc: utc.toISO(),
|
|
offset: dt.offset
|
|
});
|
|
|
|
return utc.toISO();
|
|
}
|
|
|
|
/**
|
|
* Format a date for display (in Eastern time)
|
|
*/
|
|
formatForDisplay(date) {
|
|
const dt = this.toDateTime(date);
|
|
if (!dt || !dt.isValid) return '';
|
|
return dt.toFormat('LLL d, yyyy h:mm a');
|
|
}
|
|
|
|
/**
|
|
* Validate if a date range is valid
|
|
*/
|
|
isValidDateRange(start, end) {
|
|
const startDt = this.toDateTime(start);
|
|
const endDt = this.toDateTime(end);
|
|
return startDt && endDt && endDt > startDt;
|
|
}
|
|
|
|
/**
|
|
* Get the current time in Eastern timezone
|
|
*/
|
|
getNow() {
|
|
return DateTime.now().setZone(this.timezone);
|
|
}
|
|
|
|
/**
|
|
* Get a date range for the last N hours
|
|
*/
|
|
getLastNHours(hours) {
|
|
const now = this.getNow();
|
|
return {
|
|
start: now.minus({ hours }),
|
|
end: now
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a date range for the last N days
|
|
* Aligns with custom day start time
|
|
*/
|
|
getLastNDays(days) {
|
|
const now = this.getNow();
|
|
const dayStart = this.getDayStart(now);
|
|
return {
|
|
start: dayStart.minus({ days }),
|
|
end: this.getDayEnd(now)
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get a date range for a specific time period
|
|
* All ranges align with custom day start time
|
|
*/
|
|
getDateRange(period) {
|
|
const now = this.getNow();
|
|
|
|
// Normalize period to handle both 'last' and 'previous' prefixes
|
|
const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period;
|
|
|
|
switch (normalizedPeriod) {
|
|
case 'custom': {
|
|
// Custom ranges are handled separately via getCustomRange
|
|
console.warn('[TimeManager] Custom ranges should use getCustomRange method');
|
|
return null;
|
|
}
|
|
case 'today': {
|
|
const dayStart = this.getDayStart(now);
|
|
return {
|
|
start: dayStart,
|
|
end: this.getDayEnd(now)
|
|
};
|
|
}
|
|
case 'yesterday': {
|
|
const yesterday = now.minus({ days: 1 });
|
|
return {
|
|
start: this.getDayStart(yesterday),
|
|
end: this.getDayEnd(yesterday)
|
|
};
|
|
}
|
|
case 'last7days': {
|
|
// For last 7 days, we want to include today and the previous 6 days
|
|
const dayStart = this.getDayStart(now);
|
|
const weekStart = dayStart.minus({ days: 6 });
|
|
return {
|
|
start: weekStart,
|
|
end: this.getDayEnd(now)
|
|
};
|
|
}
|
|
case 'last30days': {
|
|
// Include today and previous 29 days
|
|
const dayStart = this.getDayStart(now);
|
|
const monthStart = dayStart.minus({ days: 29 });
|
|
return {
|
|
start: monthStart,
|
|
end: this.getDayEnd(now)
|
|
};
|
|
}
|
|
case 'last90days': {
|
|
// Include today and previous 89 days
|
|
const dayStart = this.getDayStart(now);
|
|
const start = dayStart.minus({ days: 89 });
|
|
return {
|
|
start,
|
|
end: this.getDayEnd(now)
|
|
};
|
|
}
|
|
case 'thisWeek': {
|
|
// Get the start of the week (Sunday) with custom hour
|
|
const weekStart = this.getWeekStart(now);
|
|
return {
|
|
start: weekStart,
|
|
end: this.getDayEnd(now)
|
|
};
|
|
}
|
|
case 'lastWeek': {
|
|
const lastWeek = now.minus({ weeks: 1 });
|
|
const weekStart = this.getWeekStart(lastWeek);
|
|
const weekEnd = weekStart.plus({ days: 6 }); // 6 days after start = Saturday
|
|
return {
|
|
start: weekStart,
|
|
end: this.getDayEnd(weekEnd)
|
|
};
|
|
}
|
|
case 'thisMonth': {
|
|
const dayStart = this.getDayStart(now);
|
|
const monthStart = dayStart.startOf('month').set({ hour: this.dayStartHour });
|
|
return {
|
|
start: monthStart,
|
|
end: this.getDayEnd(now)
|
|
};
|
|
}
|
|
case 'lastMonth': {
|
|
const lastMonth = now.minus({ months: 1 });
|
|
const monthStart = lastMonth.startOf('month').set({ hour: this.dayStartHour });
|
|
const monthEnd = monthStart.plus({ months: 1 }).minus({ days: 1 });
|
|
return {
|
|
start: monthStart,
|
|
end: this.getDayEnd(monthEnd)
|
|
};
|
|
}
|
|
default:
|
|
console.warn(`[TimeManager] Unknown period: ${period}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Format a duration in milliseconds to a human-readable string
|
|
*/
|
|
formatDuration(ms) {
|
|
return DateTime.fromMillis(ms).toFormat("hh'h' mm'm' ss's'");
|
|
}
|
|
|
|
/**
|
|
* Get relative time string (e.g., "2 hours ago")
|
|
*/
|
|
getRelativeTime(date) {
|
|
const dt = this.toDateTime(date);
|
|
if (!dt) return '';
|
|
return dt.toRelative();
|
|
}
|
|
|
|
/**
|
|
* Get a custom date range using exact dates and times provided
|
|
* @param {string} startDate - ISO string or Date for range start
|
|
* @param {string} endDate - ISO string or Date for range end
|
|
* @returns {Object} Object with start and end DateTime objects
|
|
*/
|
|
getCustomRange(startDate, endDate) {
|
|
if (!startDate || !endDate) {
|
|
console.error("[TimeManager] Custom range requires both start and end dates");
|
|
return null;
|
|
}
|
|
|
|
const start = this.toDateTime(startDate);
|
|
const end = this.toDateTime(endDate);
|
|
|
|
if (!start || !end || !start.isValid || !end.isValid) {
|
|
console.error("[TimeManager] Invalid dates provided for custom range");
|
|
return null;
|
|
}
|
|
|
|
// Validate the range
|
|
if (end < start) {
|
|
console.error("[TimeManager] End date must be after start date");
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
start,
|
|
end
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Get the previous period's date range based on the current period
|
|
* @param {string} period - The current period
|
|
* @param {DateTime} now - The current datetime (optional)
|
|
* @returns {Object} Object with start and end DateTime objects
|
|
*/
|
|
getPreviousPeriod(period, now = this.getNow()) {
|
|
const normalizedPeriod = period.startsWith('previous') ? period.replace('previous', 'last') : period;
|
|
|
|
switch (normalizedPeriod) {
|
|
case 'today': {
|
|
const yesterday = now.minus({ days: 1 });
|
|
return {
|
|
start: this.getDayStart(yesterday),
|
|
end: this.getDayEnd(yesterday)
|
|
};
|
|
}
|
|
case 'yesterday': {
|
|
const twoDaysAgo = now.minus({ days: 2 });
|
|
return {
|
|
start: this.getDayStart(twoDaysAgo),
|
|
end: this.getDayEnd(twoDaysAgo)
|
|
};
|
|
}
|
|
case 'last7days': {
|
|
const dayStart = this.getDayStart(now);
|
|
const currentStart = dayStart.minus({ days: 6 });
|
|
const prevEnd = currentStart.minus({ milliseconds: 1 });
|
|
const prevStart = prevEnd.minus({ days: 6 });
|
|
return {
|
|
start: prevStart,
|
|
end: prevEnd
|
|
};
|
|
}
|
|
case 'last30days': {
|
|
const dayStart = this.getDayStart(now);
|
|
const currentStart = dayStart.minus({ days: 29 });
|
|
const prevEnd = currentStart.minus({ milliseconds: 1 });
|
|
const prevStart = prevEnd.minus({ days: 29 });
|
|
return {
|
|
start: prevStart,
|
|
end: prevEnd
|
|
};
|
|
}
|
|
case 'last90days': {
|
|
const dayStart = this.getDayStart(now);
|
|
const currentStart = dayStart.minus({ days: 89 });
|
|
const prevEnd = currentStart.minus({ milliseconds: 1 });
|
|
const prevStart = prevEnd.minus({ days: 89 });
|
|
return {
|
|
start: prevStart,
|
|
end: prevEnd
|
|
};
|
|
}
|
|
case 'thisWeek': {
|
|
const weekStart = this.getWeekStart(now);
|
|
const prevEnd = weekStart.minus({ milliseconds: 1 });
|
|
const prevStart = this.getWeekStart(prevEnd);
|
|
return {
|
|
start: prevStart,
|
|
end: prevEnd
|
|
};
|
|
}
|
|
case 'lastWeek': {
|
|
const lastWeekStart = this.getWeekStart(now.minus({ weeks: 1 }));
|
|
const prevEnd = lastWeekStart.minus({ milliseconds: 1 });
|
|
const prevStart = this.getWeekStart(prevEnd);
|
|
return {
|
|
start: prevStart,
|
|
end: prevEnd
|
|
};
|
|
}
|
|
case 'thisMonth': {
|
|
const monthStart = now.startOf('month').set({ hour: this.dayStartHour });
|
|
const prevEnd = monthStart.minus({ milliseconds: 1 });
|
|
const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour });
|
|
return {
|
|
start: prevStart,
|
|
end: prevEnd
|
|
};
|
|
}
|
|
case 'lastMonth': {
|
|
const lastMonthStart = now.minus({ months: 1 }).startOf('month').set({ hour: this.dayStartHour });
|
|
const prevEnd = lastMonthStart.minus({ milliseconds: 1 });
|
|
const prevStart = prevEnd.startOf('month').set({ hour: this.dayStartHour });
|
|
return {
|
|
start: prevStart,
|
|
end: prevEnd
|
|
};
|
|
}
|
|
default:
|
|
console.warn(`[TimeManager] No previous period defined for: ${period}`);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
groupEventsByInterval(events, interval = 'day', property = null) {
|
|
if (!events?.length) return [];
|
|
|
|
const groupedData = new Map();
|
|
const now = DateTime.now().setZone('America/New_York');
|
|
|
|
for (const event of events) {
|
|
const datetime = DateTime.fromISO(event.attributes.datetime);
|
|
let groupKey;
|
|
|
|
switch (interval) {
|
|
case 'hour':
|
|
groupKey = datetime.startOf('hour').toISO();
|
|
break;
|
|
case 'day':
|
|
groupKey = datetime.startOf('day').toISO();
|
|
break;
|
|
case 'week':
|
|
groupKey = datetime.startOf('week').toISO();
|
|
break;
|
|
case 'month':
|
|
groupKey = datetime.startOf('month').toISO();
|
|
break;
|
|
default:
|
|
groupKey = datetime.startOf('day').toISO();
|
|
}
|
|
|
|
const existingGroup = groupedData.get(groupKey) || {
|
|
datetime: groupKey,
|
|
count: 0,
|
|
value: 0
|
|
};
|
|
|
|
existingGroup.count++;
|
|
|
|
if (property) {
|
|
// Extract property value from event
|
|
const props = event.attributes?.event_properties || event.attributes?.properties || {};
|
|
let value = 0;
|
|
|
|
if (property === '$value') {
|
|
// Special case for $value - use event value
|
|
value = Number(event.attributes?.value || 0);
|
|
} else {
|
|
// Otherwise get from properties
|
|
value = Number(props[property] || 0);
|
|
}
|
|
|
|
existingGroup.value = (existingGroup.value || 0) + value;
|
|
}
|
|
|
|
groupedData.set(groupKey, existingGroup);
|
|
}
|
|
|
|
// Convert to array and sort by datetime
|
|
return Array.from(groupedData.values())
|
|
.sort((a, b) => DateTime.fromISO(a.datetime) - DateTime.fromISO(b.datetime));
|
|
}
|
|
}
|