diff --git a/packages/@n8n/api-types/src/frontend-settings.ts b/packages/@n8n/api-types/src/frontend-settings.ts index c6b06234577..3c3d29da108 100644 --- a/packages/@n8n/api-types/src/frontend-settings.ts +++ b/packages/@n8n/api-types/src/frontend-settings.ts @@ -272,6 +272,7 @@ export type FrontendModuleSettings = { summary: boolean; dashboard: boolean; dateRanges: InsightsDateRange[]; + earliestDataDate: string | null; }; /** diff --git a/packages/cli/src/modules/insights/__tests__/insights.module.test.ts b/packages/cli/src/modules/insights/__tests__/insights.module.test.ts index 10d1246d5ba..503a611ff7a 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.module.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.module.test.ts @@ -6,6 +6,7 @@ import type { MockProxy } from 'jest-mock-extended'; import { mock } from 'jest-mock-extended'; import { InstanceSettings } from 'n8n-core'; +import { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository'; import { InsightsModule } from '../insights.module'; import { InsightsService } from '../insights.service'; @@ -29,6 +30,7 @@ describe('InsightsModule', () => { Container.set(InstanceSettings, mockInstanceSettings); Container.set(Logger, mockLogger()); Container.set(LicenseState, mock()); + Container.set(InsightsByPeriodRepository, mock()); Container.set( InsightsService, new InsightsService( diff --git a/packages/cli/src/modules/insights/__tests__/insights.settings.test.ts b/packages/cli/src/modules/insights/__tests__/insights.settings.test.ts index e144598bb09..88db9089ac7 100644 --- a/packages/cli/src/modules/insights/__tests__/insights.settings.test.ts +++ b/packages/cli/src/modules/insights/__tests__/insights.settings.test.ts @@ -1,33 +1,37 @@ import type { LicenseState } from '@n8n/backend-common'; import { mock } from 'jest-mock-extended'; +import type { InsightsByPeriodRepository } from '../database/repositories/insights-by-period.repository'; import { InsightsSettings } from '../insights.settings'; describe('InsightsSettings', () => { let licenseMock: jest.Mocked; + let repositoryMock: jest.Mocked; let insightsSettings: InsightsSettings; beforeAll(() => { licenseMock = mock(); - insightsSettings = new InsightsSettings(licenseMock); + repositoryMock = mock(); + repositoryMock.getEarliestDataDate.mockResolvedValue(null); + insightsSettings = new InsightsSettings(licenseMock, repositoryMock); }); - test('returns correct summary and dashboard licenses', () => { + test('returns correct summary and dashboard licenses', async () => { licenseMock.isInsightsSummaryLicensed.mockReturnValue(true); licenseMock.isInsightsDashboardLicensed.mockReturnValue(true); - const result = insightsSettings.settings(); + const result = await insightsSettings.settings(); expect(result.summary).toBe(true); expect(result.dashboard).toBe(true); }); describe('dateRanges', () => { - test('returns correct ranges when hourly data is enabled and max history is unlimited', () => { + test('returns correct ranges when hourly data is enabled and max history is unlimited', async () => { licenseMock.getInsightsMaxHistory.mockReturnValue(-1); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); - const result = insightsSettings.settings(); + const result = await insightsSettings.settings(); expect(result.dateRanges).toEqual([ { key: 'day', licensed: true, granularity: 'hour' }, @@ -40,11 +44,11 @@ describe('InsightsSettings', () => { ]); }); - test('returns correct ranges when hourly data is enabled and max history is 365 days', () => { + test('returns correct ranges when hourly data is enabled and max history is 365 days', async () => { licenseMock.getInsightsMaxHistory.mockReturnValue(365); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); - const result = insightsSettings.settings(); + const result = await insightsSettings.settings(); expect(result.dateRanges).toEqual([ { key: 'day', licensed: true, granularity: 'hour' }, @@ -57,11 +61,11 @@ describe('InsightsSettings', () => { ]); }); - test('returns correct ranges when hourly data is disabled and max history is 30 days', () => { + test('returns correct ranges when hourly data is disabled and max history is 30 days', async () => { licenseMock.getInsightsMaxHistory.mockReturnValue(30); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false); - const result = insightsSettings.settings(); + const result = await insightsSettings.settings(); expect(result.dateRanges).toEqual([ { key: 'day', licensed: false, granularity: 'hour' }, @@ -74,11 +78,11 @@ describe('InsightsSettings', () => { ]); }); - test('returns correct ranges when max history is less than 7 days', () => { + test('returns correct ranges when max history is less than 7 days', async () => { licenseMock.getInsightsMaxHistory.mockReturnValue(5); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(false); - const result = insightsSettings.settings(); + const result = await insightsSettings.settings(); expect(result.dateRanges).toEqual([ { key: 'day', licensed: false, granularity: 'hour' }, @@ -91,11 +95,11 @@ describe('InsightsSettings', () => { ]); }); - test('returns correct ranges when max history is 90 days and hourly data is enabled', () => { + test('returns correct ranges when max history is 90 days and hourly data is enabled', async () => { licenseMock.getInsightsMaxHistory.mockReturnValue(90); licenseMock.isInsightsHourlyDataLicensed.mockReturnValue(true); - const result = insightsSettings.settings(); + const result = await insightsSettings.settings(); expect(result.dateRanges).toEqual([ { key: 'day', licensed: true, granularity: 'hour' }, 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 09c73024199..b4377da74c1 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 @@ -436,4 +436,11 @@ export class InsightsByPeriodRepository extends Repository { return { affected: result.affected }; } + + async getEarliestDataDate(): Promise { + const result = await this.createQueryBuilder('ibp') + .select('MIN(ibp.periodStart)', 'minDate') + .getRawOne<{ minDate: Date | string | null }>(); + return result?.minDate ? new Date(result.minDate) : null; + } } diff --git a/packages/cli/src/modules/insights/insights.module.ts b/packages/cli/src/modules/insights/insights.module.ts index 3ba880b2b19..12d2e4235bf 100644 --- a/packages/cli/src/modules/insights/insights.module.ts +++ b/packages/cli/src/modules/insights/insights.module.ts @@ -26,7 +26,7 @@ export class InsightsModule implements ModuleInterface { async settings() { const { InsightsSettings } = await import('./insights.settings'); - return Container.get(InsightsSettings).settings(); + return await Container.get(InsightsSettings).settings(); } @OnShutdown() diff --git a/packages/cli/src/modules/insights/insights.settings.ts b/packages/cli/src/modules/insights/insights.settings.ts index 339e070895d..44ab754015f 100644 --- a/packages/cli/src/modules/insights/insights.settings.ts +++ b/packages/cli/src/modules/insights/insights.settings.ts @@ -2,16 +2,22 @@ import { LicenseState } from '@n8n/backend-common'; import { Service } from '@n8n/di'; import { INSIGHTS_DATE_RANGE_KEYS, keyRangeToDays } from './insights.constants'; +import { InsightsByPeriodRepository } from './database/repositories/insights-by-period.repository'; @Service() export class InsightsSettings { - constructor(private readonly licenseState: LicenseState) {} + constructor( + private readonly licenseState: LicenseState, + private readonly insightsByPeriodRepository: InsightsByPeriodRepository, + ) {} - settings() { + async settings() { + const earliest = await this.insightsByPeriodRepository.getEarliestDataDate(); return { summary: this.licenseState.isInsightsSummaryLicensed(), dashboard: this.licenseState.isInsightsDashboardLicensed(), dateRanges: this.getAvailableDateRanges(), + earliestDataDate: earliest?.toISOString() ?? null, }; } diff --git a/packages/frontend/@n8n/i18n/src/locales/en.json b/packages/frontend/@n8n/i18n/src/locales/en.json index d257f11b4c1..77718d48b8c 100644 --- a/packages/frontend/@n8n/i18n/src/locales/en.json +++ b/packages/frontend/@n8n/i18n/src/locales/en.json @@ -5740,6 +5740,10 @@ "insights.dashboard.table.estimate": "Estimate", "insights.dashboard.title": "Insights", "insights.dashboard.search.placeholder": "All projects", + "insights.dashboard.dataRangeAlert.title": "Your selected range goes back further than your data", + "insights.dashboard.dataRangeAlert.description": "Insights started collecting data on {date}. The 1 earlier day in your range does not have any data available. | Insights started collecting data on {date}. The {days} earlier days in your range do not have any data available.", + "insights.dashboard.dataRangeAlert.descriptionNoData": "Insights started collecting data on {date}. Your selected range does not have any data available.", + "insights.dashboard.dataRangeAlert.dismiss": "Dismiss", "insights.dashboard.paywall.title": "Upgrade to access more detailed insights", "insights.dashboard.paywall.description": "Gain access to more granular, per-workflow insights and visual breakdown of production executions over different time periods.", "insights.banner.title.timeSaved.tooltip": "Total time saved calculated from your estimated time savings per execution across all workflows", diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts index 303a804916a..5abe11aab84 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts +++ b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.test.ts @@ -81,6 +81,7 @@ const moduleSettings: FrontendModuleSettings = { insights: { summary: true, dashboard: true, + earliestDataDate: null, dateRanges: [ { key: 'day', @@ -264,6 +265,46 @@ const selectProject = async (projectName: string | null) => { await userEvent.click(teamProject as Element); }; +const setupStores = () => { + insightsStore = mockedStore(useInsightsStore); + projectsStore = mockedStore(useProjectsStore); + + insightsStore.isSummaryEnabled = true; + insightsStore.isDashboardEnabled = true; + + projectsStore.availableProjects = projects; + projectsStore.getAvailableProjects = vi.fn().mockResolvedValue(projects); + projectsStore.searchProjects.mockResolvedValue({ count: projects.length, data: projects }); + projectsStore.globalProjectPermissions = { list: true }; + + insightsStore.summary = { + state: mockSummaryData, + isLoading: false, + execute: vi.fn(), + isReady: true, + error: null, + then: vi.fn(), + }; + + insightsStore.charts = { + state: mockChartsData, + isLoading: false, + execute: vi.fn(), + isReady: true, + error: null, + then: vi.fn(), + }; + + insightsStore.table = { + state: mockTableData, + isLoading: false, + execute: vi.fn(), + isReady: true, + error: null, + then: vi.fn(), + }; +}; + describe('InsightsDashboard', () => { beforeEach(() => { vi.clearAllMocks(); @@ -275,45 +316,7 @@ describe('InsightsDashboard', () => { initialState: { settings: { settings: defaultSettings, moduleSettings } }, }); - insightsStore = mockedStore(useInsightsStore); - projectsStore = mockedStore(useProjectsStore); - - insightsStore.isSummaryEnabled = true; - insightsStore.isDashboardEnabled = true; - - // Mock projects store - projectsStore.availableProjects = projects; - projectsStore.getAvailableProjects = vi.fn().mockResolvedValue(projects); - projectsStore.searchProjects.mockResolvedValue({ count: projects.length, data: projects }); - projectsStore.globalProjectPermissions = { list: true }; - - // Mock async states - insightsStore.summary = { - state: mockSummaryData, - isLoading: false, - execute: vi.fn(), - isReady: true, - error: null, - then: vi.fn(), - }; - - insightsStore.charts = { - state: mockChartsData, - isLoading: false, - execute: vi.fn(), - isReady: true, - error: null, - then: vi.fn(), - }; - - insightsStore.table = { - state: mockTableData, - isLoading: false, - execute: vi.fn(), - isReady: true, - error: null, - then: vi.fn(), - }; + setupStores(); }); describe('Component Rendering', () => { @@ -719,4 +722,33 @@ describe('InsightsDashboard', () => { }); }); }); + + describe('Default date range initialization', () => { + it('should default to 7 days ago when earliestDataDate is null', () => { + const { getByRole } = renderComponent({ props: { insightType: INSIGHT_TYPES.TOTAL } }); + // System date is 2000-12-19, so 7 days ago is 2000-12-12 + expect(getByRole('button', { name: '12 Dec - 19 Dec, 2000' })).toBeInTheDocument(); + }); + + it('should clamp default range start to the earliest date with data with a maximum of 7', () => { + // earliestDataDate is Dec 15, which is within the last 7 days (Dec 12–19) + createTestingPinia({ + initialState: { + settings: { + settings: defaultSettings, + moduleSettings: { + ...moduleSettings, + insights: { + ...moduleSettings.insights!, + earliestDataDate: '2000-12-15T00:00:00.000Z', + }, + }, + }, + }, + }); + setupStores(); + const { getByRole } = renderComponent({ props: { insightType: INSIGHT_TYPES.TOTAL } }); + expect(getByRole('button', { name: '15 Dec - 19 Dec, 2000' })).toBeInTheDocument(); + }); + }); }); diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue index 0fa11354901..925c808413e 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue +++ b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDashboard.vue @@ -8,7 +8,7 @@ import { useAvailableProjectSearch } from '@/features/collaboration/projects/pro import InsightsSummary from '@/features/execution/insights/components/InsightsSummary.vue'; import { useInsightsStore } from '@/features/execution/insights/insights.store'; import type { DateValue } from '@internationalized/date'; -import { getLocalTimeZone, today } from '@internationalized/date'; +import { getLocalTimeZone, parseDate, today } from '@internationalized/date'; import type { InsightsSummaryType } from '@n8n/api-types'; import { useI18n } from '@n8n/i18n'; import { @@ -24,6 +24,7 @@ import { useRoute } from 'vue-router'; import { INSIGHT_TYPES } from '../insights.constants'; import { getAdjustedDateRange, getTimeRangeLabels, timeRangeMappings } from '../insights.utils'; import InsightsDataRangePicker from './InsightsDataRangePicker.vue'; +import InsightsDateRangeAlert from './InsightsDateRangeAlert.vue'; import { N8nHeading, N8nSpinner } from '@n8n/design-system'; const InsightsPaywall = defineAsyncComponent( @@ -114,11 +115,21 @@ const minimumValue = shallowRef( maxDate.copy().subtract({ days: timeRangeMappings[maxLicensedDate] }), ); +function getDefaultRangeStart(): DateValue { + const sevenDaysAgo = maxDate.copy().subtract({ days: 7 }); + if (insightsStore.earliestDataDate) { + const earliestDate = parseDate(insightsStore.earliestDataDate.substring(0, 10)); + // If data only starts within the last 7 days, begin from first data point + return earliestDate.compare(sevenDaysAgo) > 0 ? earliestDate : sevenDaysAgo; + } + return sevenDaysAgo; +} + const range = shallowRef<{ start: DateValue; end: DateValue; }>({ - start: maxDate.copy().subtract({ days: 7 }), + start: getDefaultRangeStart(), end: maxDate.copy(), }); @@ -236,6 +247,14 @@ onBeforeMount(async () => { /> + + { + beforeEach(() => { + createTestingPinia({ + initialState: { settings: { settings: defaultSettings } }, + }); + }); + + it('does not render when earliestDataDate is null', () => { + const { queryByTestId } = renderComponent({ + props: { + earliestDataDate: null, + rangeStart: new CalendarDate(2026, 6, 1), + rangeEnd: new CalendarDate(2026, 6, 30), + }, + }); + + expect(queryByTestId('insights-date-range-alert')).not.toBeInTheDocument(); + }); + + it('does not render when rangeStart is on the earliest data date', () => { + const { queryByTestId } = renderComponent({ + props: { + earliestDataDate: '2026-06-11T00:00:00.000Z', + rangeStart: new CalendarDate(2026, 6, 11), + rangeEnd: new CalendarDate(2026, 6, 30), + }, + }); + + expect(queryByTestId('insights-date-range-alert')).not.toBeInTheDocument(); + }); + + it('does not render when rangeStart is after the earliest data date', () => { + const { queryByTestId } = renderComponent({ + props: { + earliestDataDate: '2026-06-11T00:00:00.000Z', + rangeStart: new CalendarDate(2026, 6, 15), + rangeEnd: new CalendarDate(2026, 6, 30), + }, + }); + + expect(queryByTestId('insights-date-range-alert')).not.toBeInTheDocument(); + }); + + it('renders the alert when rangeStart is before the earliest data date', () => { + const { getByTestId } = renderComponent({ + props: { + earliestDataDate: '2026-06-11T00:00:00.000Z', + rangeStart: new CalendarDate(2026, 6, 9), + rangeEnd: new CalendarDate(2026, 6, 30), + }, + }); + + expect(getByTestId('insights-date-range-alert')).toBeInTheDocument(); + }); + + it('shows the correct title', () => { + const { getByText } = renderComponent({ + props: { + earliestDataDate: '2026-06-11T00:00:00.000Z', + rangeStart: new CalendarDate(2026, 6, 9), + rangeEnd: new CalendarDate(2026, 6, 30), + }, + }); + + expect(getByText('Your selected range goes back further than your data')).toBeInTheDocument(); + }); + + it('uses the singular form when exactly 1 day has no data', () => { + const { getByTestId } = renderComponent({ + props: { + earliestDataDate: '2026-06-11T00:00:00.000Z', + rangeStart: new CalendarDate(2026, 6, 10), + rangeEnd: new CalendarDate(2026, 6, 30), + }, + }); + + expect(getByTestId('insights-date-range-alert').textContent).toContain('1 earlier day'); + }); + + it('uses the plural form when multiple days have no data', () => { + const { getByTestId } = renderComponent({ + props: { + earliestDataDate: '2026-06-11T00:00:00.000Z', + rangeStart: new CalendarDate(2026, 6, 9), + rangeEnd: new CalendarDate(2026, 6, 30), + }, + }); + + expect(getByTestId('insights-date-range-alert').textContent).toContain('2 earlier days'); + }); + + it('shows no-data description when the entire range is before the earliest data date', () => { + const { getByTestId } = renderComponent({ + props: { + earliestDataDate: '2026-06-25T00:00:00.000Z', + rangeStart: new CalendarDate(2026, 6, 1), + rangeEnd: new CalendarDate(2026, 6, 10), + }, + }); + + expect(getByTestId('insights-date-range-alert').textContent).toContain( + 'Your selected range does not have any data available.', + ); + }); + + it('hides the alert and emits dismiss when the dismiss button is clicked', async () => { + const { getByTestId, queryByTestId, emitted } = renderComponent({ + props: { + earliestDataDate: '2026-06-11T00:00:00.000Z', + rangeStart: new CalendarDate(2026, 6, 9), + rangeEnd: new CalendarDate(2026, 6, 30), + }, + }); + + expect(getByTestId('insights-date-range-alert')).toBeInTheDocument(); + + await getByTestId('insights-date-range-alert-dismiss').click(); + + expect(queryByTestId('insights-date-range-alert')).not.toBeInTheDocument(); + expect(emitted('dismiss')).toHaveLength(1); + }); +}); diff --git a/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDateRangeAlert.vue b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDateRangeAlert.vue new file mode 100644 index 00000000000..b2a65870910 --- /dev/null +++ b/packages/frontend/editor-ui/src/features/execution/insights/components/InsightsDateRangeAlert.vue @@ -0,0 +1,90 @@ + + + diff --git a/packages/frontend/editor-ui/src/features/execution/insights/insights.store.ts b/packages/frontend/editor-ui/src/features/execution/insights/insights.store.ts index 9226f76f8d1..310b9bbe315 100644 --- a/packages/frontend/editor-ui/src/features/execution/insights/insights.store.ts +++ b/packages/frontend/editor-ui/src/features/execution/insights/insights.store.ts @@ -68,6 +68,10 @@ export const useInsightsStore = defineStore('insights', () => { const dateRanges = computed(() => settingsStore.moduleSettings.insights?.dateRanges ?? []); + const earliestDataDate = computed( + () => settingsStore.moduleSettings.insights?.earliestDataDate ?? null, + ); + return { globalInsightsPermissions, isInsightsEnabled, @@ -78,5 +82,6 @@ export const useInsightsStore = defineStore('insights', () => { charts, table, dateRanges, + earliestDataDate, }; });