diff --git a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts index 700e9dc7fa7..8c644b2d60a 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.service.integration.test.ts @@ -229,6 +229,7 @@ describe('InsightsService', () => { test('mixed period data are summarized correctly', async () => { // ARRANGE + // current period await createCompactedInsightsEvent(workflow, { type: 'success', @@ -338,11 +339,11 @@ describe('InsightsService', () => { // ASSERT expect(summary).toEqual({ - averageRunTime: { value: 0, unit: 'millisecond', deviation: -7157.8 }, + averageRunTime: { value: 0, unit: 'millisecond', deviation: -8947.25 }, failed: { value: 20, unit: 'count', deviation: 18 }, - failureRate: { value: 0.909, unit: 'ratio', deviation: 0.509 }, + failureRate: { value: 0.87, unit: 'ratio', deviation: 0.37 }, timeSaved: { value: 0, unit: 'minute', deviation: -15 }, - total: { value: 22, unit: 'count', deviation: 17 }, + total: { value: 23, unit: 'count', deviation: 19 }, }); }); @@ -687,7 +688,7 @@ describe('InsightsService', () => { type: 'success', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 13, hours: 23 }), + periodStart: now.minus({ days: 14 }).startOf('day'), }); // Out of date range insight (should not be included) @@ -696,7 +697,7 @@ describe('InsightsService', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ days: 14 }), + periodStart: now.minus({ days: 15 }), }); } @@ -843,21 +844,20 @@ describe('InsightsService', () => { }); // Barely in range insight (should be included) - // 1 hour before 14 days ago await createCompactedInsightsEvent(workflow, { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: now.minus({ days: 13, hours: 23 }), + periodStart: now.minus({ days: 14 }).startOf('day'), }); // Out of date range insight (should not be included) - // 14 days ago + // 15 days ago await createCompactedInsightsEvent(workflow, { type: 'success', value: 1, periodUnit: 'day', - periodStart: now.minus({ days: 14 }), + periodStart: now.minus({ days: 15 }), }); } @@ -1001,12 +1001,11 @@ describe('InsightsService', () => { }); // Barely in range insight (should be included) - // 1 hour before 14 days ago await createCompactedInsightsEvent(workflow, { type: workflow === workflow1 ? 'success' : 'failure', value: 1, periodUnit: 'hour', - periodStart: DateTime.utc().minus({ days: 13, hours: 23 }), + periodStart: DateTime.utc().minus({ days: 14 }).startOf('day'), }); // Out of date range insight (should not be included) @@ -1015,7 +1014,7 @@ describe('InsightsService', () => { type: 'success', value: 1, periodUnit: 'day', - periodStart: DateTime.utc().minus({ days: 14 }), + periodStart: DateTime.utc().minus({ days: 15 }), }); } @@ -1199,25 +1198,29 @@ describe('InsightsService', () => { licenseStateMock.isInsightsHourlyDataLicensed.mockReturnValue(false); licenseStateMock.getInsightsMaxHistory.mockReturnValue(30); - const today = DateTime.now().startOf('day'); - const startDate = today.minus({ hours: 12 }).toJSDate(); - const endDate = today.toJSDate(); + const startDate = DateTime.now().minus({ days: 3 }).startOf('day'); + const endDate = startDate.plus({ hours: 10 }); - expect(() => insightsService.validateDateFiltersLicense({ startDate, endDate })).toThrowError( - new UserError('Hourly data is not available with your current license'), - ); + expect(() => + insightsService.validateDateFiltersLicense({ + startDate: startDate.toJSDate(), + endDate: endDate.toJSDate(), + }), + ).toThrowError(new UserError('Hourly data is not available with your current license')); }); test('does not throw if granularity is hour and hourly data is licensed', () => { licenseStateMock.isInsightsHourlyDataLicensed.mockReturnValue(true); licenseStateMock.getInsightsMaxHistory.mockReturnValue(30); - const today = DateTime.now().startOf('day'); - const startDate = today.minus({ hours: 12 }).toJSDate(); - const endDate = today.toJSDate(); + const startDate = DateTime.now().minus({ days: 3 }).startOf('day'); + const endDate = startDate.endOf('day'); expect(() => - insightsService.validateDateFiltersLicense({ startDate, endDate }), + insightsService.validateDateFiltersLicense({ + startDate: startDate.toJSDate(), + endDate: endDate.toJSDate(), + }), ).not.toThrow(); }); 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 new file mode 100644 index 00000000000..2e68234192b --- /dev/null +++ b/packages/cli/src/modules/insights/database/repositories/__tests__/insights-by-period-query.helper.test.ts @@ -0,0 +1,502 @@ +import type { DatabaseConfig } from '@n8n/config'; +import { DateTime } from 'luxon'; + +import { getDateRangesCommonTableExpressionQuery } from '../insights-by-period-query.helper'; + +describe('getDateRangesCommonTableExpressionQuery', () => { + const now = DateTime.utc(2025, 10, 8, 8, 51, 27); + + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(now.toJSDate()); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe.each([ + ['sqlite', 'SQLite'], + ['postgresdb', 'PostgreSQL'], + ['mysqldb', 'MySQL'], + ['mariadb', 'MariaDB'], + ])('%s', (dbType: DatabaseConfig['type']) => { + describe('hour periodicity (1 day - startDate == endDate)', () => { + test('last 24 hours (endDate is today)', () => { + const startDate = now.startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + 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 + } + }); + + test('yesterday (specific day)', () => { + const startDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-2 days', 'start of day')"); // prev_start_date + expect(result).toContain("datetime('now', '-1 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '2 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '1 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 2 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 1 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('7 days ago (specific day)', () => { + const startDate = now.minus({ days: 7 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 7 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-8 days', 'start of day')"); // prev_start_date + expect(result).toContain("datetime('now', '-7 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-6 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '8 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '7 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '6 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 8 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 7 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 6 DAY))'); // end_date + } + }); + + test('14 days ago (specific day)', () => { + const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-15 days', 'start of day')"); // prev_start_date + expect(result).toContain("datetime('now', '-14 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-13 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '15 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '14 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '13 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 15 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 14 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 13 DAY))'); // end_date + } + }); + + test('X days ago (specific day far in the past)', () => { + // 109 days ago (2025-06-21) + const startDate = now.minus({ days: 109 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 109 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-110 days', 'start of day')"); // prev_start_date + expect(result).toContain("datetime('now', '-109 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-108 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '110 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '109 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '108 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 110 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 109 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 108 DAY))'); // end_date + } + }); + }); + + describe('day periodicity (2-30 days)', () => { + test('last 7 days (endDate is today)', () => { + const startDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-12 days', 'start of day')"); // prev_start_date (6 + 6) + expect(result).toContain("datetime('now', '-6 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '12 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '6 days')"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 12 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 6 DAY))'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('last 14 days (endDate is today)', () => { + const startDate = now.minus({ days: 13 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-26 days', 'start of day')"); // prev_start_date (13 + 13) + expect(result).toContain("datetime('now', '-13 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '26 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '13 days')"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 26 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 13 DAY))'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('last 30 days (endDate is today)', () => { + const startDate = now.minus({ days: 29 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-58 days', 'start of day')"); // prev_start_date (29 + 29) + expect(result).toContain("datetime('now', '-29 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '58 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '29 days')"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 58 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 29 DAY))'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('2 days range (specific historical range)', () => { + const startDate = now.minus({ days: 2 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-3 days', 'start of day')"); // prev_start_date (2 + 1) + expect(result).toContain("datetime('now', '-2 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '3 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '2 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 3 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 2 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('5 days range (specific historical range)', () => { + const startDate = now.minus({ days: 10 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-14 days', 'start of day')"); // prev_start_date (10 + 4) + expect(result).toContain("datetime('now', '-10 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-5 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '14 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '10 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '5 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 14 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 10 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 5 DAY))'); // end_date + } + }); + + test('7 days range (specific historical range)', () => { + const startDate = now.minus({ days: 12 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 6 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-18 days', 'start of day')"); // prev_start_date (12 + 6) + expect(result).toContain("datetime('now', '-12 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', '-5 days', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '18 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '12 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '5 days')"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 18 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 12 DAY))'); // start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 5 DAY))'); // end_date + } + }); + + test('14 days range (specific historical range)', () => { + const startDate = now.minus({ days: 14 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-27 days', 'start of day')"); // prev_start_date (14 + 13) + expect(result).toContain("datetime('now', '-14 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '27 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '14 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 27 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 14 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('30 days range (specific historical range)', () => { + const startDate = now.minus({ days: 30 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-59 days', 'start of day')"); // prev_start_date (30 + 29) + expect(result).toContain("datetime('now', '-30 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '59 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '30 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 59 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 30 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + }); + + describe('week periodicity (31+ days)', () => { + test('last 90 days (endDate is today)', () => { + const startDate = now.minus({ days: 89 }).startOf('day').toJSDate(); + const endDate = now.startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-178 days', 'start of day')"); // prev_start_date (89 + 89) + expect(result).toContain("datetime('now', '-89 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '178 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '89 days')"); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 178 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 89 DAY))'); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('last 6 months (endDate is today)', () => { + 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; + + if (dbType === 'sqlite') { + expect(result).toContain(`datetime('now', '-${prevDaysBack} days', 'start of day')`); // prev_start_date + expect(result).toContain(`datetime('now', '-${daysBack} days', 'start of day')`); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${prevDaysBack} days')`); // prev_start_date + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${daysBack} days')`); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prevDaysBack} DAY))`); // prev_start_date + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${daysBack} DAY))`); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('last year (endDate is today)', () => { + 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; + + if (dbType === 'sqlite') { + expect(result).toContain(`datetime('now', '-${prevDaysBack} days', 'start of day')`); // prev_start_date + expect(result).toContain(`datetime('now', '-${daysBack} days', 'start of day')`); // start_date + expect(result).toContain("datetime('now')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${prevDaysBack} days')`); // prev_start_date + expect(result).toContain(`DATE_TRUNC('day', NOW() - INTERVAL '${daysBack} days')`); // start_date + expect(result).toContain('NOW()'); // end_date + } else { + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${prevDaysBack} DAY))`); // prev_start_date + expect(result).toContain(`DATE(DATE_SUB(NOW(), INTERVAL ${daysBack} DAY))`); // start_date + expect(result).toContain('NOW()'); // end_date + } + }); + + test('31 days range (specific historical range)', () => { + const startDate = now.minus({ days: 31 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-61 days', 'start of day')"); // prev_start_date (31 + 30) + expect(result).toContain("datetime('now', '-31 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '61 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '31 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 61 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 31 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('90 days range (specific historical range)', () => { + const startDate = now.minus({ days: 90 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-179 days', 'start of day')"); // prev_start_date (90 + 89) + expect(result).toContain("datetime('now', '-90 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '179 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '90 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 179 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 90 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('180 days range (specific historical range)', () => { + const startDate = now.minus({ days: 180 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-359 days', 'start of day')"); // prev_start_date (180 + 179) + expect(result).toContain("datetime('now', '-180 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '359 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '180 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 359 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 180 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('360 days range (specific historical range)', () => { + const startDate = now.minus({ days: 360 }).startOf('day').toJSDate(); + const endDate = now.minus({ days: 1 }).startOf('day').toJSDate(); + + const result = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); + + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-719 days', 'start of day')"); // prev_start_date (360 + 359) + expect(result).toContain("datetime('now', '-360 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '719 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '360 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 719 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 360 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + }); + + describe('edge cases', () => { + test('handles date with time component correctly', () => { + // Date with time should be treated as start of day + 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 }); + + // Should calculate based on start of day values (2-day range) + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-3 days', 'start of day')"); // prev_start_date (2 + 1) + expect(result).toContain("datetime('now', '-2 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '3 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '2 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 3 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 2 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + + test('handles same day with different times correctly (hour periodicity)', () => { + 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 }); + + // Should treat as single day (yesterday) - hour periodicity + if (dbType === 'sqlite') { + expect(result).toContain("datetime('now', '-2 days', 'start of day')"); // prev_start_date (1 + 1) + expect(result).toContain("datetime('now', '-1 days', 'start of day')"); // start_date + expect(result).toContain("datetime('now', 'start of day')"); // end_date + } else if (dbType === 'postgresdb') { + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '2 days')"); // prev_start_date + expect(result).toContain("DATE_TRUNC('day', NOW() - INTERVAL '1 days')"); // start_date + expect(result).toContain("DATE_TRUNC('day', NOW())"); // end_date + } else { + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 2 DAY))'); // prev_start_date + expect(result).toContain('DATE(DATE_SUB(NOW(), INTERVAL 1 DAY))'); // start_date + expect(result).toContain('DATE(NOW())'); // end_date + } + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..a6ca190c577 --- /dev/null +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period-query.helper.ts @@ -0,0 +1,133 @@ +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 + * + * 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) + * @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: DatabaseConfig['type']; + startDate: Date; + endDate: Date; +}) => { + const today = DateTime.now().startOf('day'); + const startDateStartOfDay = DateTime.fromJSDate(startDate).startOf('day'); + const endDateStartOfDay = DateTime.fromJSDate(endDate).startOf('day'); + + const daysFromEndDateToToday = Math.floor(today.diff(endDateStartOfDay, 'days').days); + const daysDiff = Math.floor(endDateStartOfDay.diff(startDateStartOfDay, 'days').days); + + const isEndDateToday = daysFromEndDateToToday === 0; + + let prevStartDateSql: string; + let startDateSql: string; + let endDateSql: string; + + if (daysDiff === 0 && isEndDateToday) { + // Last 24 hours + prevStartDateSql = getDatetimeSql({ dbType, daysFromToday: 2, useStartOfDay: false }); + startDateSql = getDatetimeSql({ dbType, daysFromToday: 1, useStartOfDay: false }); + endDateSql = getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false }); + } else { + // Calculate the date range (minimum 1 day) for previous period + const dateRangeInDays = Math.max(1, daysDiff); + const daysFromStartDateToToday = Math.floor(today.diff(startDateStartOfDay, 'days').days); + const prevStartDaysFromToday = daysFromStartDateToToday + dateRangeInDays; + + prevStartDateSql = getDatetimeSql({ + dbType, + daysFromToday: prevStartDaysFromToday, + useStartOfDay: true, + }); + + startDateSql = getDatetimeSql({ + dbType, + daysFromToday: daysFromStartDateToToday, + useStartOfDay: true, + }); + + endDateSql = isEndDateToday + ? getDatetimeSql({ dbType, daysFromToday: 0, useStartOfDay: false }) + : getDatetimeSql({ dbType, daysFromToday: daysFromEndDateToToday - 1, useStartOfDay: true }); + } + + return sql`SELECT + ${prevStartDateSql} AS prev_start_date, + ${startDateSql} AS start_date, + ${endDateSql} AS end_date + `; +}; diff --git a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts index 2eb583433a3..2a00b358a7f 100644 --- a/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts +++ b/packages/cli/src/modules/insights/database/repositories/insights-by-period.repository.ts @@ -6,6 +6,7 @@ import { DataSource, LessThanOrEqual, Repository } from '@n8n/typeorm'; import { DateTime } from 'luxon'; import { z } from 'zod'; +import { getDateRangesCommonTableExpressionQuery } from './insights-by-period-query.helper'; import { InsightsByPeriod } from '../entities/insights-by-period'; import type { PeriodUnit, TypeUnit } from '../entities/insights-shared'; import { PeriodUnitToNumber, TypeToNumber } from '../entities/insights-shared'; @@ -140,7 +141,7 @@ export class InsightsByPeriodRepository extends Repository { }>; } - getAggregationQuery(periodUnit: PeriodUnit) { + private getAggregationQuery(periodUnit: PeriodUnit) { // Get the start period expression depending on the period unit and database type const periodStartExpr = this.getPeriodStartExpr(periodUnit); @@ -268,18 +269,6 @@ export class InsightsByPeriodRepository extends Repository { } } - private getAgeLimitQuery(maxAgeInDays: number) { - if (maxAgeInDays === 0) { - return dbType === 'sqlite' ? "datetime('now')" : 'NOW()'; - } - - return dbType === 'sqlite' - ? `datetime('now', '-${maxAgeInDays} days')` - : dbType === 'postgresdb' - ? `NOW() - INTERVAL '${maxAgeInDays} days'` - : `DATE_SUB(NOW(), INTERVAL ${maxAgeInDays} DAY)`; - } - async getPreviousAndCurrentPeriodTypeAggregates({ startDate, endDate, @@ -291,25 +280,14 @@ export class InsightsByPeriodRepository extends Repository { total_value: string | number; }> > { - const { daysFromStartDateToToday, daysFromEndDateToToday, dateRangeInDays } = - this.getDateRangesDaysLimits({ - startDate, - endDate, - }); - - const cte = sql` - SELECT - ${this.getAgeLimitQuery(daysFromStartDateToToday)} AS current_start, - ${this.getAgeLimitQuery(daysFromEndDateToToday)} AS current_end, - ${this.getAgeLimitQuery(daysFromStartDateToToday + dateRangeInDays)} AS previous_start - `; + const cte = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); const rawRowsQuery = this.createQueryBuilder('insights') .addCommonTableExpression(cte, 'date_ranges') .select( sql` CASE - WHEN insights.periodStart >= date_ranges.current_start AND insights.periodStart <= date_ranges.current_end + WHEN insights.periodStart >= date_ranges.start_date AND insights.periodStart < date_ranges.end_date THEN 'current' ELSE 'previous' END @@ -320,8 +298,8 @@ export class InsightsByPeriodRepository extends Repository { .addSelect('SUM(value)', 'total_value') // Use a cross join with the CTE .innerJoin('date_ranges', 'date_ranges', '1=1') - .where('insights.periodStart >= date_ranges.previous_start') - .andWhere('insights.periodStart <= date_ranges.current_end') + .where('insights.periodStart >= date_ranges.prev_start_date') + .andWhere('insights.periodStart < date_ranges.end_date') // Group by both period and type .groupBy('period') .addGroupBy('insights.type'); @@ -360,16 +338,7 @@ export class InsightsByPeriodRepository extends Repository { const [sortField, sortOrder] = this.parseSortingParams(sortBy); const sumOfExecutions = sql`SUM(CASE WHEN insights.type IN (${TypeToNumber.success.toString()}, ${TypeToNumber.failure.toString()}) THEN value ELSE 0 END)`; - const { daysFromStartDateToToday, daysFromEndDateToToday } = this.getDateRangesDaysLimits({ - startDate, - endDate, - }); - - const cte = sql` - SELECT - ${this.getAgeLimitQuery(daysFromStartDateToToday)} AS start_date, - ${this.getAgeLimitQuery(daysFromEndDateToToday)} AS end_date - `; + const cte = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); const rawRowsQuery = this.createQueryBuilder('insights') .addCommonTableExpression(cte, 'date_ranges') @@ -396,7 +365,7 @@ export class InsightsByPeriodRepository extends Repository { // Use a cross join with the CTE .innerJoin('date_ranges', 'date_ranges', '1=1') .where('insights.periodStart >= date_ranges.start_date') - .andWhere('insights.periodStart <= date_ranges.end_date') + .andWhere('insights.periodStart < date_ranges.end_date') .groupBy('metadata.workflowId') .addGroupBy('metadata.workflowName') .addGroupBy('metadata.projectId') @@ -426,16 +395,7 @@ export class InsightsByPeriodRepository extends Repository { startDate: Date; endDate: Date; }) { - const { daysFromStartDateToToday, daysFromEndDateToToday } = this.getDateRangesDaysLimits({ - startDate, - endDate, - }); - - const cte = sql` - SELECT - ${this.getAgeLimitQuery(daysFromStartDateToToday)} AS start_date, - ${this.getAgeLimitQuery(daysFromEndDateToToday)} AS end_date - `; + const cte = getDateRangesCommonTableExpressionQuery({ dbType, startDate, endDate }); const typesAggregation = insightTypes.map((type) => { return `SUM(CASE WHEN insights.type = ${TypeToNumber[type]} THEN value ELSE 0 END) AS "${displayTypeName[TypeToNumber[type]]}"`; @@ -461,27 +421,6 @@ export class InsightsByPeriodRepository extends Repository { return aggregatedInsightsByTimeParser.parse(rawRows); } - private getDateRangesDaysLimits({ startDate, endDate }: { startDate: Date; endDate: Date }) { - const today = DateTime.now().startOf('day'); - const startDateStartOfDay = DateTime.fromJSDate(startDate).startOf('day'); - const endDateStartOfDay = DateTime.fromJSDate(endDate).startOf('day'); - - let daysFromStartDateToToday = today.diff(startDateStartOfDay, 'days').days; - // ensure that at least one day is covered - if (daysFromStartDateToToday < 1) { - daysFromStartDateToToday = 1; - } - const daysFromEndDateToToday = today.diff(endDateStartOfDay, 'days').days; - - const dateRangeInDays = daysFromStartDateToToday - daysFromEndDateToToday; - - return { - daysFromStartDateToToday, - daysFromEndDateToToday, - dateRangeInDays, - }; - } - async pruneOldData(maxAgeInDays: number): Promise<{ affected: number | null | undefined }> { const thresholdDate = DateTime.now().minus({ days: maxAgeInDays }).startOf('day').toJSDate(); const result = await this.delete({ diff --git a/packages/cli/src/modules/insights/insights.controller.ts b/packages/cli/src/modules/insights/insights.controller.ts index 69dbe356b25..d23d46ba1cd 100644 --- a/packages/cli/src/modules/insights/insights.controller.ts +++ b/packages/cli/src/modules/insights/insights.controller.ts @@ -161,7 +161,10 @@ export class InsightsController { if (query.dateRange) { const maxAgeInDays = keyRangeToDays[query.dateRange]; return { - startDate: DateTime.now().minus({ days: maxAgeInDays }).toJSDate(), + startDate: + maxAgeInDays === 1 + ? DateTime.now().startOf('day').toJSDate() + : DateTime.now().minus({ days: maxAgeInDays }).toJSDate(), endDate: today, }; } diff --git a/packages/cli/src/modules/insights/insights.service.ts b/packages/cli/src/modules/insights/insights.service.ts index 7c45f4a8cc7..959f87a4372 100644 --- a/packages/cli/src/modules/insights/insights.service.ts +++ b/packages/cli/src/modules/insights/insights.service.ts @@ -275,7 +275,7 @@ export class InsightsService { const endDateTime = DateTime.fromJSDate(endDate); const differenceInDays = endDateTime.diff(startDateTime, 'days').days; - if (differenceInDays <= 1) { + if (differenceInDays < 1) { return 'hour'; }