563 lines
20 KiB
JavaScript
563 lines
20 KiB
JavaScript
// 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)
|
|
);
|
|
}
|
|
}
|