fix(core): Return insights when only one day is selected (#20543)

This commit is contained in:
Irénée 2025-10-10 08:39:48 +01:00 committed by GitHub
parent d7a70d643e
commit dc72c23d6a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 674 additions and 94 deletions

View File

@ -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();
});

View File

@ -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
}
});
});
});
});

View File

@ -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
`;
};

View File

@ -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<InsightsByPeriod> {
}>;
}
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<InsightsByPeriod> {
}
}
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<InsightsByPeriod> {
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<InsightsByPeriod> {
.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<InsightsByPeriod> {
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<InsightsByPeriod> {
// 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<InsightsByPeriod> {
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<InsightsByPeriod> {
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({

View File

@ -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,
};
}

View File

@ -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';
}