Skip to content

Commit ca84d28

Browse files
authored
1 parent d1a4902 commit ca84d28

5 files changed

Lines changed: 198 additions & 4 deletions

File tree

packages/twenty-front/src/modules/settings/admin-panel/ai/components/SettingsAdminAI.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,8 @@ export const SettingsAdminAI = () => {
6262
const billing = useAtomStateValue(billingState);
6363
const isBillingEnabled = billing?.isBillingEnabled ?? false;
6464
const hasEnterpriseAccess =
65-
isBillingEnabled || currentWorkspace?.hasValidEnterpriseKey === true;
65+
isBillingEnabled ||
66+
currentWorkspace?.hasValidEnterpriseValidityToken === true;
6667
const [usagePeriod, setUsagePeriod] = useState<PeriodPreset>('30d');
6768
const periodOptions = getPeriodOptions();
6869
const usageDates = getPeriodDates(usagePeriod);

packages/twenty-front/src/pages/settings/ai/components/SettingsAiUsageTab.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import { SettingsEnterpriseFeatureGateCard } from '@/settings/components/Setting
77
import { UsageBreakdownPieSection } from '@/settings/usage/components/UsageBreakdownPieSection';
88
import { UsageByUserTableSection } from '@/settings/usage/components/UsageByUserTableSection';
99
import { UsageDailyChartSection } from '@/settings/usage/components/UsageDailyChartSection';
10+
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
1011
import { AI_OPERATION_TYPES } from '@/settings/usage/constants/AiOperationTypes';
1112
import { useUsageAnalyticsData } from '@/settings/usage/hooks/useUsageAnalyticsData';
12-
import { UsageSectionSkeleton } from '@/settings/usage/components/UsageSectionSkeleton';
1313
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
1414
import { t } from '@lingui/core/macro';
1515
import { SettingsPath } from 'twenty-shared/types';
@@ -25,7 +25,8 @@ export const SettingsAiUsageTab = () => {
2525
const isClickHouseConfigured = useAtomStateValue(isClickHouseConfiguredState);
2626

2727
const hasEnterpriseAccess =
28-
isBillingEnabled || currentWorkspace?.hasValidEnterpriseKey === true;
28+
isBillingEnabled ||
29+
currentWorkspace?.hasValidEnterpriseValidityToken === true;
2930

3031
const shouldSkipQuery = !hasEnterpriseAccess || !isClickHouseConfigured;
3132

packages/twenty-server/src/engine/core-modules/usage/services/usage-analytics.service.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Injectable } from '@nestjs/common';
44

55
import { ClickHouseService } from 'src/database/clickHouse/clickHouse.service';
66
import { formatDateForClickHouse } from 'src/database/clickHouse/clickHouse.util';
7+
import { fillUsageTimeSeriesGaps } from 'src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util';
78
import { toDisplayCredits } from 'src/engine/core-modules/usage/utils/to-display-credits.util';
89
import { toDollars } from 'src/engine/core-modules/usage/utils/to-dollars.util';
910

@@ -230,9 +231,15 @@ export class UsageAnalyticsService {
230231
},
231232
);
232233

233-
return rows.map((row) => ({
234+
const points = rows.map((row) => ({
234235
date: row.date,
235236
creditsUsed: row.creditsUsedMicro,
236237
}));
238+
239+
return fillUsageTimeSeriesGaps({
240+
rows: points,
241+
periodStart,
242+
periodEnd,
243+
});
237244
}
238245
}
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { fillUsageTimeSeriesGaps } from 'src/engine/core-modules/usage/utils/fill-usage-time-series-gaps.util';
2+
3+
describe('fillUsageTimeSeriesGaps', () => {
4+
it('should return all-zero series across the period when no rows are returned', () => {
5+
const result = fillUsageTimeSeriesGaps({
6+
rows: [],
7+
periodStart: new Date('2026-04-20T00:00:00.000Z'),
8+
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
9+
});
10+
11+
expect(result).toEqual([
12+
{ date: '2026-04-20', creditsUsed: 0 },
13+
{ date: '2026-04-21', creditsUsed: 0 },
14+
{ date: '2026-04-22', creditsUsed: 0 },
15+
{ date: '2026-04-23', creditsUsed: 0 },
16+
]);
17+
});
18+
19+
it('should fill internal gaps with zero while preserving real values', () => {
20+
const result = fillUsageTimeSeriesGaps({
21+
rows: [
22+
{ date: '2026-04-20', creditsUsed: 100 },
23+
{ date: '2026-04-22', creditsUsed: 250 },
24+
],
25+
periodStart: new Date('2026-04-20T00:00:00.000Z'),
26+
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
27+
});
28+
29+
expect(result).toEqual([
30+
{ date: '2026-04-20', creditsUsed: 100 },
31+
{ date: '2026-04-21', creditsUsed: 0 },
32+
{ date: '2026-04-22', creditsUsed: 250 },
33+
{ date: '2026-04-23', creditsUsed: 0 },
34+
]);
35+
});
36+
37+
it('should pad trailing dates with zero when data stops before period end (Félix bug)', () => {
38+
const result = fillUsageTimeSeriesGaps({
39+
rows: [
40+
{ date: '2026-04-15', creditsUsed: 50 },
41+
{ date: '2026-04-16', creditsUsed: 75 },
42+
],
43+
periodStart: new Date('2026-04-15T00:00:00.000Z'),
44+
periodEnd: new Date('2026-04-20T23:59:59.999Z'),
45+
});
46+
47+
expect(result).toEqual([
48+
{ date: '2026-04-15', creditsUsed: 50 },
49+
{ date: '2026-04-16', creditsUsed: 75 },
50+
{ date: '2026-04-17', creditsUsed: 0 },
51+
{ date: '2026-04-18', creditsUsed: 0 },
52+
{ date: '2026-04-19', creditsUsed: 0 },
53+
{ date: '2026-04-20', creditsUsed: 0 },
54+
]);
55+
});
56+
57+
it('should pad leading dates with zero when data starts after period start', () => {
58+
const result = fillUsageTimeSeriesGaps({
59+
rows: [{ date: '2026-04-22', creditsUsed: 99 }],
60+
periodStart: new Date('2026-04-20T00:00:00.000Z'),
61+
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
62+
});
63+
64+
expect(result).toEqual([
65+
{ date: '2026-04-20', creditsUsed: 0 },
66+
{ date: '2026-04-21', creditsUsed: 0 },
67+
{ date: '2026-04-22', creditsUsed: 99 },
68+
{ date: '2026-04-23', creditsUsed: 0 },
69+
]);
70+
});
71+
72+
it('should return data unchanged when every day in period is already present', () => {
73+
const rows = [
74+
{ date: '2026-04-20', creditsUsed: 1 },
75+
{ date: '2026-04-21', creditsUsed: 2 },
76+
{ date: '2026-04-22', creditsUsed: 3 },
77+
];
78+
79+
const result = fillUsageTimeSeriesGaps({
80+
rows,
81+
periodStart: new Date('2026-04-20T00:00:00.000Z'),
82+
periodEnd: new Date('2026-04-22T23:59:59.999Z'),
83+
});
84+
85+
expect(result).toEqual(rows);
86+
});
87+
88+
it('should return a single entry when period spans one day', () => {
89+
const result = fillUsageTimeSeriesGaps({
90+
rows: [{ date: '2026-04-23', creditsUsed: 42 }],
91+
periodStart: new Date('2026-04-23T00:00:00.000Z'),
92+
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
93+
});
94+
95+
expect(result).toEqual([{ date: '2026-04-23', creditsUsed: 42 }]);
96+
});
97+
98+
it('should return an empty array when periodStart is after periodEnd', () => {
99+
const result = fillUsageTimeSeriesGaps({
100+
rows: [],
101+
periodStart: new Date('2026-04-25T00:00:00.000Z'),
102+
periodEnd: new Date('2026-04-20T23:59:59.999Z'),
103+
});
104+
105+
expect(result).toEqual([]);
106+
});
107+
108+
it('should treat periodEnd as exclusive to match SQL `timestamp < periodEnd` semantics', () => {
109+
const result = fillUsageTimeSeriesGaps({
110+
rows: [{ date: '2026-04-23', creditsUsed: 10 }],
111+
periodStart: new Date('2026-04-22T00:00:00.000Z'),
112+
periodEnd: new Date('2026-04-24T00:00:00.000Z'),
113+
});
114+
115+
expect(result).toEqual([
116+
{ date: '2026-04-22', creditsUsed: 0 },
117+
{ date: '2026-04-23', creditsUsed: 10 },
118+
]);
119+
});
120+
121+
it('should return dates in ascending order', () => {
122+
const result = fillUsageTimeSeriesGaps({
123+
rows: [
124+
{ date: '2026-04-22', creditsUsed: 5 },
125+
{ date: '2026-04-20', creditsUsed: 1 },
126+
],
127+
periodStart: new Date('2026-04-20T00:00:00.000Z'),
128+
periodEnd: new Date('2026-04-23T23:59:59.999Z'),
129+
});
130+
131+
expect(result.map((point) => point.date)).toEqual([
132+
'2026-04-20',
133+
'2026-04-21',
134+
'2026-04-22',
135+
'2026-04-23',
136+
]);
137+
});
138+
});
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import { type Temporal } from 'temporal-polyfill';
2+
import {
3+
isPlainDateAfter,
4+
isPlainDateBeforeOrEqual,
5+
parseToPlainDateOrThrow,
6+
} from 'twenty-shared/utils';
7+
8+
import { type UsageTimeSeriesPoint } from 'src/engine/core-modules/usage/services/usage-analytics.service';
9+
10+
type FillUsageTimeSeriesGapsParams = {
11+
rows: UsageTimeSeriesPoint[];
12+
periodStart: Date;
13+
periodEnd: Date;
14+
};
15+
16+
export const fillUsageTimeSeriesGaps = ({
17+
rows,
18+
periodStart,
19+
periodEnd,
20+
}: FillUsageTimeSeriesGapsParams): UsageTimeSeriesPoint[] => {
21+
const startDate = parseToPlainDateOrThrow(periodStart.toISOString());
22+
const lastIncludedInstant = new Date(periodEnd.getTime() - 1);
23+
const endDate = parseToPlainDateOrThrow(lastIncludedInstant.toISOString());
24+
25+
if (isPlainDateAfter(startDate, endDate)) {
26+
return [];
27+
}
28+
29+
const rowsByDate = new Map<string, UsageTimeSeriesPoint>();
30+
31+
for (const row of rows) {
32+
rowsByDate.set(row.date, row);
33+
}
34+
35+
const filled: UsageTimeSeriesPoint[] = [];
36+
let currentDateCursor: Temporal.PlainDate = startDate;
37+
38+
while (isPlainDateBeforeOrEqual(currentDateCursor, endDate)) {
39+
const key = currentDateCursor.toString();
40+
const existing = rowsByDate.get(key);
41+
42+
filled.push(existing ?? { date: key, creditsUsed: 0 });
43+
currentDateCursor = currentDateCursor.add({ days: 1 });
44+
}
45+
46+
return filled;
47+
};

0 commit comments

Comments
 (0)