diff --git a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts index 11150e4f5cf..601c90e5e07 100644 --- a/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts +++ b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts @@ -1,86 +1,10 @@ import type { DatabaseConfig } from '@n8n/config'; import { DateTime } from 'luxon'; -import { getDateRangesCommonTableExpressionQuery } from '../insights-by-period-query.helper'; - -function expectLastXDaysDateRangeQuery(params: { - result: string; - dbType: DatabaseConfig['type']; - prevStartDateOffset: number; - startDateOffset: number; -}) { - const { result, dbType, prevStartDateOffset: prev, startDateOffset: start } = params; - - if (dbType === 'sqlite') { - if (prev === 0) { - expect(result).toContain("datetime('now') AS prev_start_date"); - } else { - expect(result).toContain(`datetime('now', '-${prev} days') AS prev_start_date`); - } - if (start === 0) { - expect(result).toContain("datetime('now') AS start_date"); - } else { - expect(result).toContain(`datetime('now', '-${start} days') AS start_date`); - } - expect(result).toContain("datetime('now') AS end_date"); - } else if (dbType === 'postgresdb') { - if (prev === 0) { - expect(result).toContain('NOW() AS prev_start_date'); - } else { - expect(result).toContain(`NOW() - INTERVAL '${prev} days' AS prev_start_date`); - } - if (start === 0) { - expect(result).toContain('NOW() AS start_date'); - } else { - expect(result).toContain(`NOW() - INTERVAL '${start} days' AS start_date`); - } - expect(result).toContain('NOW() AS end_date'); - } else { - if (prev === 0) { - expect(result).toContain('NOW() AS prev_start_date'); - } else { - expect(result).toContain(`DATE_SUB(NOW(), INTERVAL ${prev} DAY) AS prev_start_date`); - } - if (start === 0) { - expect(result).toContain('NOW() AS start_date'); - } else { - expect(result).toContain(`DATE_SUB(NOW(), INTERVAL ${start} DAY) AS start_date`); - } - expect(result).toContain('NOW() AS end_date'); - } -} - -function expectStartOfDayDateRangeQuery(params: { - result: string; - dbType: DatabaseConfig['type']; - prevStartDateOffset: number; - startDateOffset: number; - endDateOffset: number; -}) { - const { - result, - dbType, - prevStartDateOffset: prev, - startDateOffset: start, - endDateOffset: end, - } = params; - - if (dbType === 'sqlite') { - expect(result).toContain(`datetime('now', '-${prev} days', 'start of day') AS prev_start_date`); - expect(result).toContain(`datetime('now', '-${start} days', 'start of day') AS start_date`); - expect(result).toContain(`datetime('now', '-${end} days', 'start of day') AS end_date`); - } else if (dbType === 'postgresdb') { - expect(result).toContain( - `DATE_TRUNC('day', NOW() - INTERVAL '${prev} days') AS prev_start_date`, - ); - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${start} days') AS start_date`); - expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${end} days') AS end_date`); - } else { - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prev} DAY)) AS prev_start_date`); - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${start} DAY)) AS start_date`); - expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${end} DAY)) AS end_date`); - } -} +import { + getDateRangesCommonTableExpressionQuery, + getDateRangesSelectQuery, +} from '../insights-by-period-query.helper'; describe('getDateRangesCommonTableExpressionQuery', () => { const now = DateTime.utc(2025, 10, 8, 8, 51, 27); @@ -105,63 +29,83 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, + dbType, + }); - if (dbType === 'sqlite') { - expect(result).toContain("datetime('now', '-2 days')"); // prev_start_date - expect(result).toContain("datetime('now', '-1 days')"); // start_date - expect(result).toContain("datetime('now')"); // end_date - } else if (dbType === 'postgresdb') { - expect(result).toContain("NOW() - INTERVAL '2 days'"); // prev_start_date - expect(result).toContain("NOW() - INTERVAL '1 days'"); // start_date - expect(result).toContain('NOW()'); // end_date - } else { - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY)'); // prev_start_date - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY)'); // start_date - expect(result).toContain('NOW()'); // end_date - } + // endDate is today but different day from startDate, so dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 2 }).startOf('day'), + startDateTime: now.minus({ days: 1 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('day before yesterday (specific day)', () => { const startDate = now.minus({ days: 2 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 3, - startDateOffset: 2, - endDateOffset: 1, }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 4 }).startOf('day'), + startDateTime: now.minus({ days: 2 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('7 days ago (specific day)', () => { const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 8, - startDateOffset: 7, - endDateOffset: 6, // the end of the range is the start of the next day }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 9 }).startOf('day'), + startDateTime: now.minus({ days: 7 }).startOf('day'), + endDateTime: now.minus({ days: 5 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('14 days ago (specific day)', () => { const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 13 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 15, - startDateOffset: 14, - endDateOffset: 13, // the end of the range is the start of the next day }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 16 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.minus({ days: 12 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('X days ago (specific day far in the past)', () => { @@ -169,14 +113,21 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 109 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 108 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 110, - startDateOffset: 109, - endDateOffset: 108, }); + + // Past range: end+1 day startOf('day') + // Duration = 2 days, so prev starts 2 days before start + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 111 }).startOf('day'), + startDateTime: now.minus({ days: 109 }).startOf('day'), + endDateTime: now.minus({ days: 107 }).startOf('day'), + }); + expect(result).toBe(expected); }); }); @@ -186,39 +137,60 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 14, // 7 + 7 - startDateOffset: 7, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 14 }).startOf('day'), + startDateTime: now.minus({ days: 7 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 14 days', () => { const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 28, // 14 + 14 - startDateOffset: 14, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 28 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 30 days', () => { const startDate = now.minus({ days: 30 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 60, // 30 + 30 - startDateOffset: 30, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 60 }).startOf('day'), + startDateTime: now.minus({ days: 30 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); }); @@ -227,70 +199,100 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 3 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 5, - startDateOffset: 3, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 3 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 6 }).startOf('day'), + startDateTime: now.minus({ days: 3 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('5 days range', () => { const startDate = now.minus({ days: 10 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 5 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 15, - startDateOffset: 10, - endDateOffset: 5, }); + + // Past range: end+1 day, duration = 6 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 16 }).startOf('day'), + startDateTime: now.minus({ days: 10 }).startOf('day'), + endDateTime: now.minus({ days: 4 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('7 days range', () => { const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 7 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 21, - startDateOffset: 14, - endDateOffset: 7, }); + + // Past range: end+1 day, duration = 8 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 22 }).startOf('day'), + startDateTime: now.minus({ days: 14 }).startOf('day'), + endDateTime: now.minus({ days: 6 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('14 days range', () => { const startDate = now.minus({ days: 15 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 29, - startDateOffset: 15, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 15 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 30 }).startOf('day'), + startDateTime: now.minus({ days: 15 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('30 days range', () => { const startDate = now.minus({ days: 53 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 23 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 83, - startDateOffset: 53, - endDateOffset: 23, }); + + // Past range: end+1 day, duration = 31 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 84 }).startOf('day'), + startDateTime: now.minus({ days: 53 }).startOf('day'), + endDateTime: now.minus({ days: 22 }).startOf('day'), + }); + expect(result).toBe(expected); }); }); }); @@ -301,43 +303,68 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 90 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 180, - startDateOffset: 90, }); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 180 }).startOf('day'), + startDateTime: now.minus({ days: 90 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('last 6 months', () => { const startDate = now.minus({ months: 6 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); - const prevDaysBack = daysBack * 2; - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: prevDaysBack, - startDateOffset: daysBack, }); + + const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day'); + const endDateTime = now.startOf('day'); + const duration = endDateTime.diff(startDateTime); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); test('last year', () => { const startDate = now.minus({ years: 1 }).startOf('day').toJSDate(); const endDate = now.startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - const daysBack = Math.floor(now.diff(DateTime.fromJSDate(startDate), 'days').days); - const prevDaysBack = daysBack * 2; - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: prevDaysBack, - startDateOffset: daysBack, }); + + const startDateTime = DateTime.fromJSDate(startDate).toUTC().startOf('day'); + const endDateTime = now.startOf('day'); + const duration = endDateTime.diff(startDateTime); + + // endDate is today but different day, dates stay as-is + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); }); @@ -346,56 +373,80 @@ describe('getDateRangesCommonTableExpressionQuery', () => { const startDate = now.minus({ days: 32 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 63, - startDateOffset: 32, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 32 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 64 }).startOf('day'), + startDateTime: now.minus({ days: 32 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('90 days range (specific historical range)', () => { const startDate = now.minus({ days: 98 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 8 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 188, - startDateOffset: 98, - endDateOffset: 8, }); + + // Past range: end+1 day, duration = 91 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 189 }).startOf('day'), + startDateTime: now.minus({ days: 98 }).startOf('day'), + endDateTime: now.minus({ days: 7 }).startOf('day'), + }); + expect(result).toBe(expected); }); test('180 days range (specific historical range)', () => { const startDate = now.minus({ days: 181 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 361, - startDateOffset: 181, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 181 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 362 }).startOf('day'), + startDateTime: now.minus({ days: 181 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); test('360 days range (specific historical range)', () => { const startDate = now.minus({ days: 361 }).startOf('day').toJSDate(); const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - expectStartOfDayDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 721, - startDateOffset: 361, - endDateOffset: 1, }); + + // Past range: end+1 day, duration = 361 days + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: now.minus({ days: 722 }).startOf('day'), + startDateTime: now.minus({ days: 361 }).startOf('day'), + endDateTime: now.startOf('day'), + }); + expect(result).toBe(expected); }); }); }); @@ -403,90 +454,78 @@ describe('getDateRangesCommonTableExpressionQuery', () => { describe('edge cases', () => { test('handles date with time component correctly', () => { // Oct 6 14:30 to Oct 7 18:45 - // Now is Oct 8 8:51:27, so Oct 7 18:45 is less than 1 full day ago - // Therefore useStartOfDay = false (not yet a full day in the past) - // daysFromEndDateToToday = Math.round(0.58) = 1 - // daysDiff = Math.round(1.18) = 1 - // daysFromStartDateToToday = Math.floor(1.76) = 1 - // prevStartDaysFromToday = 1 + 1 = 2 + // Now is Oct 8 8:51:27, so Oct 7 18:45 is in the past const startDate = DateTime.utc(2025, 10, 6, 14, 30, 0).toJSDate(); const endDate = DateTime.utc(2025, 10, 7, 18, 45, 30).toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, + dbType, + }); - // Verify the actual output based on the calculation - if (dbType === 'sqlite') { - expect(result).toContain("datetime('now', '-2 days') AS prev_start_date"); - expect(result).toContain("datetime('now', '-1 days') AS start_date"); - expect(result).toContain("datetime('now', '-1 days') AS end_date"); - } else if (dbType === 'postgresdb') { - expect(result).toContain("NOW() - INTERVAL '2 days' AS prev_start_date"); - expect(result).toContain("NOW() - INTERVAL '1 days' AS start_date"); - expect(result).toContain("NOW() - INTERVAL '1 days' AS end_date"); - } else { - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 2 DAY) AS prev_start_date'); - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS start_date'); - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS end_date'); - } + // Past range, take full days + const startDateTime = DateTime.utc(2025, 10, 6, 14, 30, 0).startOf('day'); + const endDateTime = DateTime.utc(2025, 10, 7, 18, 45, 30).plus({ days: 1 }).startOf('day'); + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); test('handles same day with different times correctly (hour periodicity)', () => { // Oct 7 9:00 to Oct 7 17:00 (same day) - // Now is Oct 8 8:51:27, so Oct 7 17:00 is less than 1 full day ago - // useStartOfDay = false - // daysDiff = 0 (same day), daysFromEndDateToToday = 1 (rounded) - // daysFromStartDateToToday = 0 (floored), prevStartDaysFromToday = 0 + 0 = 0 + // Now is Oct 8 8:51:27 const startDate = DateTime.utc(2025, 10, 7, 9, 0, 0).toJSDate(); const endDate = DateTime.utc(2025, 10, 7, 17, 0, 0).toJSDate(); - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - - // Verify the actual output based on the calculation - if (dbType === 'sqlite') { - expect(result).toContain("datetime('now') AS prev_start_date"); - expect(result).toContain("datetime('now') AS start_date"); - expect(result).toContain("datetime('now', '-1 days') AS end_date"); - } else if (dbType === 'postgresdb') { - expect(result).toContain('NOW() AS prev_start_date'); - expect(result).toContain('NOW() AS start_date'); - expect(result).toContain("NOW() - INTERVAL '1 days' AS end_date"); - } else { - expect(result).toContain('NOW() AS prev_start_date'); - expect(result).toContain('NOW() AS start_date'); - expect(result).toContain('DATE_SUB(NOW(), INTERVAL 1 DAY) AS end_date'); - } - }); - - test('handles daylight saving time transition correctly', () => { - // Simulate DST transition: Oct 22 (GMT+0200) to Nov 5 (GMT+0100) - // Same wall-clock time but different timezone offset - // Oct 26 2025 is when DST ends in Europe (clocks go back 1 hour) - const startDate = DateTime.fromObject( - { year: 2025, month: 10, day: 22, hour: 12, minute: 37, second: 56 }, - { zone: 'Europe/Paris' }, - ).toJSDate(); - const endDate = DateTime.fromObject( - { year: 2025, month: 11, day: 5, hour: 12, minute: 37, second: 56 }, - { zone: 'Europe/Paris' }, - ).toJSDate(); - - // Mock current time to be Nov 5 - jest.setSystemTime(endDate); - - const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); - - // With DST normalization: Oct 22 to Nov 5 is 14 calendar days - // The function detects same wall-clock time but different timezone offset - // and normalizes to calculate correct calendar days - expectLastXDaysDateRangeQuery({ - result, + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, dbType, - prevStartDateOffset: 28, // 14 + 14 - startDateOffset: 14, }); - // Restore original mock time - jest.setSystemTime(now.toJSDate()); + // Past range, take full days + const startDateTime = DateTime.utc(2025, 10, 7, 9, 0, 0).startOf('day'); + const endDateTime = DateTime.utc(2025, 10, 7, 17, 0, 0).plus({ days: 1 }).startOf('day'); + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); + }); + + test('handle current day as both start and end date', () => { + const startDate = now.toJSDate(); + const endDate = now.toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ + startDate, + endDate, + dbType, + }); + + // startDate and endDate are today, so start is startOf('day'), end is now + const startDateTime = now.startOf('day'); + const endDateTime = now; + const duration = endDateTime.diff(startDateTime); + + const expected = getDateRangesSelectQuery({ + dbType, + prevStartDateTime: startDateTime.minus(duration), + startDateTime, + endDateTime, + }); + expect(result).toBe(expected); }); }); }); diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts index 2f3bfd8a2df..3b90c7d5894 100644 --- a/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts @@ -2,148 +2,83 @@ import type { DatabaseConfig } from '@n8n/config'; import { sql } from '@n8n/db'; import { DateTime } from 'luxon'; -/** - * Generates database-specific SQL for a datetime value relative to now - * @param dbType - The database type - * @param daysFromToday - Number of days back from today (0 = now) - * @param useStartOfDay - Whether to truncate to start of day (00:00:00) - */ -const getDatetimeSql = ({ - dbType, - daysFromToday, - useStartOfDay = false, -}: { - dbType: DatabaseConfig['type']; - daysFromToday: number; - useStartOfDay?: boolean; -}): string => { - // Handle "now" case - if (daysFromToday === 0 && !useStartOfDay) { - return dbType === 'sqlite' ? "datetime('now')" : 'NOW()'; - } - - // SQLite - if (dbType === 'sqlite') { - if (daysFromToday === 0 && useStartOfDay) { - return "datetime('now', 'start of day')"; - } - if (useStartOfDay) { - return `datetime('now', '-${daysFromToday} days', 'start of day')`; - } - return `datetime('now', '-${daysFromToday} days')`; - } - - // PostgreSQL - if (dbType === 'postgresdb') { - if (daysFromToday === 0 && useStartOfDay) { - return "DATE_TRUNC('day', NOW())"; - } - if (useStartOfDay) { - return `DATE_TRUNC('day', NOW() - INTERVAL '${daysFromToday} days')`; - } - return `NOW() - INTERVAL '${daysFromToday} days'`; - } - - // MySQL/MariaDB - if (daysFromToday === 0 && useStartOfDay) { - return 'DATE(NOW())'; - } - if (useStartOfDay) { - return `DATE(DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY))`; - } - return `DATE_SUB(NOW(), INTERVAL ${daysFromToday} DAY)`; -}; - /** * Generates a SQL Common Table Expression (CTE) query that provides three date boundaries for insights queries * * Behavior: - * - If startDate and endDate are the same and today - * - returns the last 24 hours: prev_start_date (2 days ago), start_date (1 day ago), end_date (now). - * - Otherwise: - * - prev_start_date: start of the day before the range - * - start_date: start of the current range - * - end_date: "now" if endDate is today, else start of the day after endDate + * - If the end date is today and the start date is also today, start date is set to the start of the day to take today's data. + * - If the end date is in the past, both start and end dates are set to the start of their respective days, to take full days. * * The SQL CTE can be joined with the insights table for filtering/aggregation. * - * @param dbType - The database type ('sqlite', 'postgresdb', 'mysqldb', 'mariadb') * @param startDate - The start date of the range (inclusive) * @param endDate - The end date of the range (inclusive, or "now" if today) + * @param dbType - The database type (postgresdb, mysqldb, mariadb, or sqlite) * @returns SQL CTE query with `prev_start_date`, `start_date`, and `end_date` columns * - `prev_start_date`: The start of the previous period (used for comparison) * - `start_date`: The start of the current period (inclusive) * - `end_date`: The end of the current period (exclusive) */ export const getDateRangesCommonTableExpressionQuery = ({ - dbType, startDate, endDate, + dbType, }: { - dbType: DatabaseConfig['type']; startDate: Date; endDate: Date; + dbType: DatabaseConfig['type']; }) => { - let today = DateTime.now(); - let startDateTime = DateTime.fromJSDate(startDate); - let endDateTime = DateTime.fromJSDate(endDate); + let startDateTime = DateTime.fromJSDate(startDate).toUTC(); + let endDateTime = DateTime.fromJSDate(endDate).toUTC(); - // If the end date is in a past day, use start of day for both dates - const useStartOfDay = today.diff(endDateTime, 'days').days >= 1; + const today = DateTime.now().toUTC(); + const isEndDateToday = endDateTime.hasSame(today, 'day'); - if (useStartOfDay) { + // Past range, take full days + if (!isEndDateToday) { startDateTime = startDateTime.startOf('day'); - endDateTime = endDateTime.startOf('day'); - today = today.startOf('day'); + endDateTime = endDateTime.plus({ days: 1 }).startOf('day'); } - // Check if times are exactly the same but timezone differs (DST transition case) - const offsetDiff = Math.abs(startDateTime.offset - endDateTime.offset); - - // If same wall-clock time but different timezone offset (max 2 hours), normalize to same timezone - if ( - startDateTime.hour === endDateTime.hour && - startDateTime.minute === endDateTime.minute && - startDateTime.second === endDateTime.second && - offsetDiff > 0 && - offsetDiff <= 120 // Max 2 hours difference in minutes - ) { - // Change the startDateTime to so that time matches - startDateTime = startDateTime.plus({ minutes: offsetDiff }); + // Today range, take all day data starting from the beginning of the day + if (isEndDateToday && startDateTime.hasSame(endDateTime, 'day')) { + startDateTime = startDateTime.startOf('day'); } - // Convert to UTC to avoid DST issues when calculating day differences - const startDateTimeUTC = startDateTime.toUTC(); - const endDateTimeUTC = endDateTime.toUTC(); - const todayUTC = today.toUTC(); + const prevStartDateTime = startDateTime.minus(endDateTime.diff(startDateTime)); - const daysFromEndDateToToday = Math.round(todayUTC.diff(endDateTimeUTC, 'days').days); - const daysDiff = Math.round(endDateTimeUTC.diff(startDateTimeUTC, 'days').days); + return getDateRangesSelectQuery({ dbType, prevStartDateTime, startDateTime, endDateTime }); +}; - const daysFromStartDateToToday = Math.floor(todayUTC.diff(startDateTimeUTC, 'days').days); - const prevStartDaysFromToday = daysFromStartDateToToday + daysDiff; +export function getDateRangesSelectQuery({ + dbType, + prevStartDateTime, + startDateTime, + endDateTime, +}: { + dbType: DatabaseConfig['type']; + prevStartDateTime: DateTime; + startDateTime: DateTime; + endDateTime: DateTime; +}) { + const prevStartStr = prevStartDateTime.toSQL({ includeZone: false, includeOffset: false }); + const startStr = startDateTime.toSQL({ includeZone: false, includeOffset: false }); + const endStr = endDateTime.toSQL({ includeZone: false, includeOffset: false }); - const prevStartDateSql = getDatetimeSql({ - dbType, - daysFromToday: prevStartDaysFromToday, - useStartOfDay, - }); - - const startDateSql = getDatetimeSql({ - dbType, - daysFromToday: daysFromStartDateToToday, - useStartOfDay, - }); - - const endDateSql = getDatetimeSql({ - dbType, - daysFromToday: daysFromEndDateToToday, - useStartOfDay, - }); + // Database-specific timestamp casting + // PostgreSQL requires explicit CAST or :: syntax for timestamp comparisons + // SQLite and MySQL/MariaDB can work with string literals in comparisons + if (dbType === 'postgresdb') { + return sql`SELECT + CAST('${prevStartStr}' AS TIMESTAMP) AS prev_start_date, + CAST('${startStr}' AS TIMESTAMP) AS start_date, + CAST('${endStr}' AS TIMESTAMP) AS end_date + `; + } return sql`SELECT - ${prevStartDateSql} AS prev_start_date, - ${startDateSql} AS start_date, - ${endDateSql} AS end_date + '${prevStartStr}' AS prev_start_date, + '${startStr}' AS start_date, + '${endStr}' AS end_date `; -}; +}