[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:
+2
-1
@@ -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;
|
||||
|
||||
|
||||
+8
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+138
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
+47
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user