[AI] ai usage line chart date gap filling (#20048)

https://github.com/user-attachments/assets/ecd37dfb-daae-41c3-8316-a9d923463eca
This commit is contained in:
nitin
2026-04-26 18:11:16 +05:30
committed by GitHub
parent d1a4902460
commit ca84d28157
5 changed files with 198 additions and 4 deletions
@@ -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<PeriodPreset>('30d');
const periodOptions = getPeriodOptions();
const usageDates = getPeriodDates(usagePeriod);
@@ -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;
@@ -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,
});
}
}
@@ -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',
]);
});
});
@@ -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<string, UsageTimeSeriesPoint>();
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;
};