diff --git a/packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx b/packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx index 34f22c5ece..995832e961 100644 --- a/packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx +++ b/packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx @@ -62,7 +62,8 @@ export const SettingsAdminAI = () => { const billing = useAtomStateValue(billingState); const isBillingEnabled = billing?.isBillingEnabled ?? false; const hasEnterpriseAccess = - isBillingEnabled || currentWorkspace?.hasValidEnterpriseKey === true; + isBillingEnabled || + currentWorkspace?.hasValidEnterpriseValidityToken === true; const [usagePeriod, setUsagePeriod] = useState('30d'); const periodOptions = getPeriodOptions(); const usageDates = getPeriodDates(usagePeriod); diff --git a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiUsageTab.tsx b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiUsageTab.tsx index 36dbd68b08..29cecab329 100644 --- a/packages/twenty-front/src/pages/settings/ai/components/SettingsAiUsageTab.tsx +++ b/packages/twenty-front/src/pages/settings/ai/components/SettingsAiUsageTab.tsx @@ -7,9 +7,9 @@ import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/Setting import { UsageBreakdownPieSection } from '@/settings/usage/components/UsageBreakdownPieSection'; import { UsageByUserTableSection } from '@/settings/usage/components/UsageByUserTableSection'; import { UsageDailyChartSection } from '@/settings/usage/components/UsageDailyChartSection'; +import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton'; import { AI_OPERATION_TYPES } from '@/settings/usage/constants/AiOperationTypes'; import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData'; -import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton'; import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue'; import { t } from '@lingui/core/macro'; import { SettingsPath } from 'twenty-shared/types'; @@ -25,7 +25,8 @@ export const SettingsAiUsageTab = () => { const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState); const hasEnterpriseAccess = - isBillingEnabled || currentWorkspace?.hasValidEnterpriseKey === true; + isBillingEnabled || + currentWorkspace?.hasValidEnterpriseValidityToken === true; const shouldSkipQuery = !hasEnterpriseAccess || !isClickHouseConfigured; diff --git a/packages/twenty-server/src/engine/core-modules/usage/services/usage-analytics.service.ts b/packages/twenty-server/src/engine/core-modules/usage/services/usage-analytics.service.ts index 4e72c1d3f5..b4438dc07f 100644 --- a/packages/twenty-server/src/engine/core-modules/usage/services/usage-analytics.service.ts +++ b/packages/twenty-server/src/engine/core-modules/usage/services/usage-analytics.service.ts @@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common'; import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service'; import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util'; +import { fillUsageTimeSeriesGaps } from 'src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util'; import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util'; import { toDollars } from 'src/engine/core-modules/usage/utils/to-dollars.util'; @@ -230,9 +231,15 @@ export class UsageAnalyticsService { }, ); - return rows.map((row) => ({ + const points = rows.map((row) => ({ date: row.date, creditsUsed: row.creditsUsedMicro, })); + + return fillUsageTimeSeriesGaps({ + rows: points, + periodStart, + periodEnd, + }); } } diff --git a/packages/twenty-server/src/engine/core-modules/usage/utils/__tests__/fill-usage-time-series-gaps.util.spec.ts b/packages/twenty-server/src/engine/core-modules/usage/utils/__tests__/fill-usage-time-series-gaps.util.spec.ts new file mode 100644 index 0000000000..84530d079f --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/usage/utils/__tests__/fill-usage-time-series-gaps.util.spec.ts @@ -0,0 +1,138 @@ +import { fillUsageTimeSeriesGaps } from 'src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util'; + +describe('fillUsageTimeSeriesGaps', () => { + it('should return all-zero series across the period when no rows are returned', () => { + const result = fillUsageTimeSeriesGaps({ + rows: [], + periodStart: new Date('2026-04-20T00:00:00.000Z'), + periodEnd: new Date('2026-04-23T23:59:59.999Z'), + }); + + expect(result).toEqual([ + { date: '2026-04-20', creditsUsed: 0 }, + { date: '2026-04-21', creditsUsed: 0 }, + { date: '2026-04-22', creditsUsed: 0 }, + { date: '2026-04-23', creditsUsed: 0 }, + ]); + }); + + it('should fill internal gaps with zero while preserving real values', () => { + const result = fillUsageTimeSeriesGaps({ + rows: [ + { date: '2026-04-20', creditsUsed: 100 }, + { date: '2026-04-22', creditsUsed: 250 }, + ], + periodStart: new Date('2026-04-20T00:00:00.000Z'), + periodEnd: new Date('2026-04-23T23:59:59.999Z'), + }); + + expect(result).toEqual([ + { date: '2026-04-20', creditsUsed: 100 }, + { date: '2026-04-21', creditsUsed: 0 }, + { date: '2026-04-22', creditsUsed: 250 }, + { date: '2026-04-23', creditsUsed: 0 }, + ]); + }); + + it('should pad trailing dates with zero when data stops before period end (Félix bug)', () => { + const result = fillUsageTimeSeriesGaps({ + rows: [ + { date: '2026-04-15', creditsUsed: 50 }, + { date: '2026-04-16', creditsUsed: 75 }, + ], + periodStart: new Date('2026-04-15T00:00:00.000Z'), + periodEnd: new Date('2026-04-20T23:59:59.999Z'), + }); + + expect(result).toEqual([ + { date: '2026-04-15', creditsUsed: 50 }, + { date: '2026-04-16', creditsUsed: 75 }, + { date: '2026-04-17', creditsUsed: 0 }, + { date: '2026-04-18', creditsUsed: 0 }, + { date: '2026-04-19', creditsUsed: 0 }, + { date: '2026-04-20', creditsUsed: 0 }, + ]); + }); + + it('should pad leading dates with zero when data starts after period start', () => { + const result = fillUsageTimeSeriesGaps({ + rows: [{ date: '2026-04-22', creditsUsed: 99 }], + periodStart: new Date('2026-04-20T00:00:00.000Z'), + periodEnd: new Date('2026-04-23T23:59:59.999Z'), + }); + + expect(result).toEqual([ + { date: '2026-04-20', creditsUsed: 0 }, + { date: '2026-04-21', creditsUsed: 0 }, + { date: '2026-04-22', creditsUsed: 99 }, + { date: '2026-04-23', creditsUsed: 0 }, + ]); + }); + + it('should return data unchanged when every day in period is already present', () => { + const rows = [ + { date: '2026-04-20', creditsUsed: 1 }, + { date: '2026-04-21', creditsUsed: 2 }, + { date: '2026-04-22', creditsUsed: 3 }, + ]; + + const result = fillUsageTimeSeriesGaps({ + rows, + periodStart: new Date('2026-04-20T00:00:00.000Z'), + periodEnd: new Date('2026-04-22T23:59:59.999Z'), + }); + + expect(result).toEqual(rows); + }); + + it('should return a single entry when period spans one day', () => { + const result = fillUsageTimeSeriesGaps({ + rows: [{ date: '2026-04-23', creditsUsed: 42 }], + periodStart: new Date('2026-04-23T00:00:00.000Z'), + periodEnd: new Date('2026-04-23T23:59:59.999Z'), + }); + + expect(result).toEqual([{ date: '2026-04-23', creditsUsed: 42 }]); + }); + + it('should return an empty array when periodStart is after periodEnd', () => { + const result = fillUsageTimeSeriesGaps({ + rows: [], + periodStart: new Date('2026-04-25T00:00:00.000Z'), + periodEnd: new Date('2026-04-20T23:59:59.999Z'), + }); + + expect(result).toEqual([]); + }); + + it('should treat periodEnd as exclusive to match SQL `timestamp < periodEnd` semantics', () => { + const result = fillUsageTimeSeriesGaps({ + rows: [{ date: '2026-04-23', creditsUsed: 10 }], + periodStart: new Date('2026-04-22T00:00:00.000Z'), + periodEnd: new Date('2026-04-24T00:00:00.000Z'), + }); + + expect(result).toEqual([ + { date: '2026-04-22', creditsUsed: 0 }, + { date: '2026-04-23', creditsUsed: 10 }, + ]); + }); + + it('should return dates in ascending order', () => { + const result = fillUsageTimeSeriesGaps({ + rows: [ + { date: '2026-04-22', creditsUsed: 5 }, + { date: '2026-04-20', creditsUsed: 1 }, + ], + periodStart: new Date('2026-04-20T00:00:00.000Z'), + periodEnd: new Date('2026-04-23T23:59:59.999Z'), + }); + + expect(result.map((point) => point.date)).toEqual([ + '2026-04-20', + '2026-04-21', + '2026-04-22', + '2026-04-23', + ]); + }); +}); diff --git a/packages/twenty-server/src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util.ts b/packages/twenty-server/src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util.ts new file mode 100644 index 0000000000..8b01282345 --- /dev/null +++ b/packages/twenty-server/src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util.ts @@ -0,0 +1,47 @@ +import { type Temporal } from 'temporal-polyfill'; +import { + isPlainDateAfter, + isPlainDateBeforeOrEqual, + parseToPlainDateOrThrow, +} from 'twenty-shared/utils'; + +import { type UsageTimeSeriesPoint } from 'src/engine/core-modules/usage/services/usage-analytics.service'; + +type FillUsageTimeSeriesGapsParams = { + rows: UsageTimeSeriesPoint[]; + periodStart: Date; + periodEnd: Date; +}; + +export const fillUsageTimeSeriesGaps = ({ + rows, + periodStart, + periodEnd, +}: FillUsageTimeSeriesGapsParams): UsageTimeSeriesPoint[] => { + const startDate = parseToPlainDateOrThrow(periodStart.toISOString()); + const lastIncludedInstant = new Date(periodEnd.getTime() - 1); + const endDate = parseToPlainDateOrThrow(lastIncludedInstant.toISOString()); + + if (isPlainDateAfter(startDate, endDate)) { + return []; + } + + const rowsByDate = new Map(); + + for (const row of rows) { + rowsByDate.set(row.date, row); + } + + const filled: UsageTimeSeriesPoint[] = []; + let currentDateCursor: Temporal.PlainDate = startDate; + + while (isPlainDateBeforeOrEqual(currentDateCursor, endDate)) { + const key = currentDateCursor.toString(); + const existing = rowsByDate.get(key); + + filled.push(existing ?? { date: key, creditsUsed: 0 }); + currentDateCursor = currentDateCursor.add({ days: 1 }); + } + + return filled; +};