Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 16 additions & 9 deletions apps/backend/src/queries/usage.queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { db } from '../db/db';
import dbConfig, { Dialect } from '../db/dbConfig';
import type { ModelCosts } from '../types/llm';
import type { Granularity, TotalUsageRecord, UsageFilter, UsageRecord, UsageSource } from '../types/usage';
import { fillMissingDates, getLookbackTimestamp } from '../utils/date';
import { fillMissingDates, getLookbackTimestamp, resolvePeriodAndGranularity } from '../utils/date';
import { getProjectDeclaredModels } from '../utils/llm';

const COST_COLS = [
Expand Down Expand Up @@ -108,10 +108,13 @@ const INFERENCE_USAGE_SOURCE_EXPR = sql<UsageSource | null>`(
)`;

export const getMessagesUsage = async (projectId: string, filter: UsageFilter): Promise<UsageRecord[]> => {
const { granularity, provider } = filter;
const { granularity, period } = resolvePeriodAndGranularity({
period: filter.period,
granularity: filter.granularity,
});
const messageDateExpr = getDateExpr(s.chatMessage.createdAt, granularity);
const inferenceDateExpr = getDateExpr(s.llmInference.createdAt, granularity);
const lookbackTs = getLookbackTimestamp(granularity);
const lookbackTs = getLookbackTimestamp(granularity, period);
const messageLookbackFilter =
dbConfig.dialect === Dialect.Postgres
? sql`${s.chatMessage.createdAt} >= ${new Date(lookbackTs).toISOString()}`
Expand All @@ -123,9 +126,9 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter):

const messageWhereConditions = [eq(s.chat.projectId, projectId), messageLookbackFilter];
const inferenceWhereConditions = [eq(s.llmInference.projectId, projectId), inferenceLookbackFilter];
if (provider) {
messageWhereConditions.push(sql`${MESSAGE_USAGE_PROVIDER_EXPR} = ${provider}`);
inferenceWhereConditions.push(eq(s.llmInference.llmProvider, provider));
if (filter.provider) {
messageWhereConditions.push(sql`${MESSAGE_USAGE_PROVIDER_EXPR} = ${filter.provider}`);
inferenceWhereConditions.push(eq(s.llmInference.llmProvider, filter.provider));
}
addUserNameFilter(messageWhereConditions, filter.userNames);
addUserNameFilter(inferenceWhereConditions, filter.userNames);
Expand Down Expand Up @@ -253,12 +256,16 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter):
.from(combinedUsage)
.groupBy(({ date }) => date);

return fillMissingDates(rows.map(normalizeMessageUsageRow), granularity);
return fillMissingDates(rows.map(normalizeMessageUsageRow), granularity, period);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When period: '6m' is requested with granularity: 'day' after 12:00 UTC, this returns an extra leading zero day. Compute the series length from UTC calendar dates or pass the resolved start/count through instead of letting fillMissingDates recompute it with rounded elapsed time.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/queries/usage.queries.ts, line 259:

<comment>When `period: '6m'` is requested with `granularity: 'day'` after 12:00 UTC, this returns an extra leading zero day. Compute the series length from UTC calendar dates or pass the resolved start/count through instead of letting `fillMissingDates` recompute it with rounded elapsed time.</comment>

<file context>
@@ -253,12 +256,16 @@ export const getMessagesUsage = async (projectId: string, filter: UsageFilter):
 		.groupBy(({ date }) => date);
 
-	return fillMissingDates(rows.map(normalizeMessageUsageRow), granularity);
+	return fillMissingDates(rows.map(normalizeMessageUsageRow), granularity, period);
 };
 
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in b304103

};

export const getTotalUsage = async (projectId: string, filter: UsageFilter): Promise<TotalUsageRecord> => {
const { granularity, provider } = filter;
const lookbackTs = getLookbackTimestamp(granularity);
const { granularity, period } = resolvePeriodAndGranularity({
period: filter.period,
granularity: filter.granularity,
});
const { provider } = filter;
const lookbackTs = getLookbackTimestamp(granularity, period);
const lookbackFilter =
dbConfig.dialect === Dialect.Postgres
? sql`${s.chatMessage.createdAt} >= ${new Date(lookbackTs).toISOString()}`
Expand Down
23 changes: 22 additions & 1 deletion apps/backend/src/types/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,32 @@ import { llmProviderSchema } from './llm';
export const granularitySchema = z.enum(['hour', 'day', 'month']);
export type Granularity = z.infer<typeof granularitySchema>;

export const USAGE_PERIODS = ['24h', '7d', '15d', '30d', '60d', '90d', '6m'] as const;
export const usagePeriodSchema = z.enum(USAGE_PERIODS);
export type UsagePeriod = z.infer<typeof usagePeriodSchema>;

export const PERIOD_CONFIG: Record<UsagePeriod, { count: number; granularity: Granularity; label: string }> = {
'24h': { count: 24, granularity: 'hour', label: 'Last 24 hours' },
'7d': { count: 7, granularity: 'day', label: 'Last 7 days' },
'15d': { count: 15, granularity: 'day', label: 'Last 15 days' },
'30d': { count: 30, granularity: 'day', label: 'Last 30 days' },
'60d': { count: 60, granularity: 'day', label: 'Last 60 days' },
'90d': { count: 90, granularity: 'day', label: 'Last 90 days' },
'6m': { count: 6, granularity: 'month', label: 'Last 6 months' },
};

export const DEFAULT_PERIOD_BY_GRANULARITY: Record<Granularity, UsagePeriod> = {
hour: '24h',
day: '15d',
month: '6m',
};

export const USAGE_SOURCES = MESSAGE_SOURCES;
export type UsageSource = (typeof USAGE_SOURCES)[number];

export const usageFilterSchema = z.object({
granularity: granularitySchema.default('day'),
granularity: granularitySchema.optional(),
period: usagePeriodSchema.optional(),
provider: llmProviderSchema.optional(),
userNames: z.array(z.string()).optional(),
sources: z.array(z.enum(USAGE_SOURCES)).optional(),
Expand Down
97 changes: 74 additions & 23 deletions apps/backend/src/utils/date.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type { Granularity, UsageRecord } from '../types/usage';
import type { Granularity, UsagePeriod, UsageRecord } from '../types/usage';
import { DEFAULT_PERIOD_BY_GRANULARITY, PERIOD_CONFIG } from '../types/usage';

export { DEFAULT_PERIOD_BY_GRANULARITY, PERIOD_CONFIG };

export function isValidIsoDateString(s: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(s)) {
Expand All @@ -9,24 +12,67 @@ export function isValidIsoDateString(s: string): boolean {
return date.getUTCFullYear() === y && date.getUTCMonth() === m - 1 && date.getUTCDate() === d;
}

export const lookbackPeriods = {
hour: 24,
day: 15,
month: 6,
};
export function getPeriodStartDate(period: UsagePeriod, now: Date = new Date()): Date {
const config = PERIOD_CONFIG[period] ?? PERIOD_CONFIG['15d'];
const date = new Date(now);

export function getLookbackTimestamp(granularity: Granularity): number {
const now = Date.now();
const periods = lookbackPeriods[granularity];
if (config.granularity === 'month') {
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth() - (config.count - 1), 1, 0, 0, 0, 0));
}

switch (granularity) {
case 'hour':
return now - periods * 60 * 60 * 1000;
case 'day':
return now - periods * 24 * 60 * 60 * 1000;
case 'month':
return now - periods * 30 * 24 * 60 * 60 * 1000;
if (config.granularity === 'hour') {
date.setUTCHours(date.getUTCHours() - (config.count - 1), 0, 0, 0);
return date;
}

date.setUTCDate(date.getUTCDate() - (config.count - 1));
date.setUTCHours(0, 0, 0, 0);
return date;
}

export function resolvePeriodAndGranularity(options?: { period?: UsagePeriod; granularity?: Granularity }): {
period: UsagePeriod;
granularity: Granularity;
count: number;
startDate: Date;
} {
const now = new Date();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a usage query crosses a UTC boundary, separate new Date() snapshots can make its SQL window and zero-filled series use different periods. Resolve the window once and pass that snapshot or resolved result through the query and date-series generation.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/utils/date.ts, line 39:

<comment>When a usage query crosses a UTC boundary, separate `new Date()` snapshots can make its SQL window and zero-filled series use different periods. Resolve the window once and pass that snapshot or resolved result through the query and date-series generation.</comment>

<file context>
@@ -9,24 +12,67 @@ export function isValidIsoDateString(s: string): boolean {
+	count: number;
+	startDate: Date;
+} {
+	const now = new Date();
+	const period =
+		options?.period && PERIOD_CONFIG[options.period]
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in b304103

const period =
options?.period && PERIOD_CONFIG[options.period]
? options.period
: options?.granularity && DEFAULT_PERIOD_BY_GRANULARITY[options.granularity]
? DEFAULT_PERIOD_BY_GRANULARITY[options.granularity]
: '15d';

const defaultGranularity = PERIOD_CONFIG[period].granularity;
const granularity = options?.granularity ?? defaultGranularity;
const startDate = getPeriodStartDate(period, now);

let count: number;
if (granularity === defaultGranularity) {
count = PERIOD_CONFIG[period].count;
} else if (granularity === 'month') {
count =
(now.getUTCFullYear() - startDate.getUTCFullYear()) * 12 +
(now.getUTCMonth() - startDate.getUTCMonth()) +
1;
} else if (granularity === 'day') {
count = Math.max(1, Math.round((now.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)) + 1);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a period is overridden to a finer bucket, Math.round adds a bucket before the requested lookback after the current bucket is sufficiently far underway. For example, 6m with daily granularity can render December 31 even though the query starts January 1. Compute the count from UTC bucket boundaries instead of rounded elapsed milliseconds.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/utils/date.ts, line 60:

<comment>When a period is overridden to a finer bucket, `Math.round` adds a bucket before the requested lookback after the current bucket is sufficiently far underway. For example, `6m` with daily granularity can render December 31 even though the query starts January 1. Compute the count from UTC bucket boundaries instead of rounded elapsed milliseconds.</comment>

<file context>
@@ -9,24 +12,67 @@ export function isValidIsoDateString(s: string): boolean {
+			(now.getUTCMonth() - startDate.getUTCMonth()) +
+			1;
+	} else if (granularity === 'day') {
+		count = Math.max(1, Math.round((now.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)) + 1);
+	} else {
+		count = Math.max(1, Math.round((now.getTime() - startDate.getTime()) / (60 * 60 * 1000)) + 1);
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in b304103

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: When a non-default granularity is selected, rounding elapsed time counts a partial current bucket as a full extra bucket. This makes 6m/day render a date before its SQL lookback, and day-period/hour renders an extra hour after :30; derive counts from UTC bucket boundaries instead.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/backend/src/utils/date.ts, line 60:

<comment>When a non-default granularity is selected, rounding elapsed time counts a partial current bucket as a full extra bucket. This makes `6m`/`day` render a date before its SQL lookback, and day-period/hour renders an extra hour after `:30`; derive counts from UTC bucket boundaries instead.</comment>

<file context>
@@ -9,24 +12,67 @@ export function isValidIsoDateString(s: string): boolean {
+			(now.getUTCMonth() - startDate.getUTCMonth()) +
+			1;
+	} else if (granularity === 'day') {
+		count = Math.max(1, Math.round((now.getTime() - startDate.getTime()) / (24 * 60 * 60 * 1000)) + 1);
+	} else {
+		count = Math.max(1, Math.round((now.getTime() - startDate.getTime()) / (60 * 60 * 1000)) + 1);
</file context>

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Addressed in b304103

} else {
count = Math.max(1, Math.round((now.getTime() - startDate.getTime()) / (60 * 60 * 1000)) + 1);
}

return {
period,
granularity,
count,
startDate,
};
}

export function getLookbackTimestamp(granularity?: Granularity, period?: UsagePeriod): number {
const resolved = resolvePeriodAndGranularity({ period, granularity });
return resolved.startDate.getTime();
}

export function formatDate(date: Date, granularity: Granularity): string {
Expand All @@ -45,15 +91,15 @@ export function formatDate(date: Date, granularity: Granularity): string {
}
}

export function generateDateSeries(granularity: Granularity): string[] {
export function generateDateSeries(granularity?: Granularity, period?: UsagePeriod): string[] {
const resolved = resolvePeriodAndGranularity({ period, granularity });
const dates: string[] = [];
const periods = lookbackPeriods[granularity];
const now = new Date();

for (let i = periods - 1; i >= 0; i--) {
for (let i = resolved.count - 1; i >= 0; i--) {
const date = new Date(now);

switch (granularity) {
switch (resolved.granularity) {
case 'hour':
date.setUTCHours(date.getUTCHours() - i, 0, 0, 0);
break;
Expand All @@ -67,7 +113,7 @@ export function generateDateSeries(granularity: Granularity): string[] {
break;
}

dates.push(formatDate(date, granularity));
dates.push(formatDate(date, resolved.granularity));
}

return dates;
Expand Down Expand Up @@ -97,9 +143,14 @@ export function formatCurrentDate(timezone?: string): string {
return tz === 'UTC' ? `${formatted} (UTC)` : `${formatted} (${tz})`;
}

export function fillMissingDates(records: UsageRecord[], granularity: Granularity): UsageRecord[] {
export function fillMissingDates(
records: UsageRecord[],
granularity?: Granularity,
period?: UsagePeriod,
): UsageRecord[] {
const resolved = resolvePeriodAndGranularity({ period, granularity });
const dateSet = new Map(records.map((r) => [r.date, r]));
const allDates = generateDateSeries(granularity);
const allDates = generateDateSeries(resolved.granularity, resolved.period);

return allDates.map(
(date) =>
Expand Down
20 changes: 20 additions & 0 deletions apps/backend/tests/usage-queries.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -174,4 +174,24 @@ describe('usage query results', () => {
totalTokens: 45,
});
});

it('supports flexible time periods (30d, 60d, 90d, 6m)', async () => {
const records30d = await getMessagesUsage(PROJECT_ID, { period: '30d' });
expect(records30d).toHaveLength(30);

const records60d = await getMessagesUsage(PROJECT_ID, { period: '60d' });
expect(records60d).toHaveLength(60);

const records90d = await getMessagesUsage(PROJECT_ID, { period: '90d' });
expect(records90d).toHaveLength(90);

const records6m = await getMessagesUsage(PROJECT_ID, { period: '6m' });
expect(records6m).toHaveLength(6);
const currentMonth = formatDate(new Date(), 'month');
expect(records6m.map((r) => r.date)).toContain(currentMonth);

// Diverging period and granularity
const records6mDaily = await getMessagesUsage(PROJECT_ID, { period: '6m', granularity: 'day' });
expect(records6mDaily.length).toBeGreaterThan(150);
});
});
46 changes: 27 additions & 19 deletions apps/frontend/src/components/settings/usage-filters.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Radio, ThumbsUp, Users, Wrench } from 'lucide-react';
import { CHAT_REPLAY_FEEDBACK_STATES, CHAT_REPLAY_TOOL_STATES, providerLabel } from '@nao/shared/types';
import { USAGE_SOURCES } from '@nao/backend/usage';
import type { Granularity, UsageSource } from '@nao/backend/usage';
import { DEFAULT_PERIOD_BY_GRANULARITY, PERIOD_CONFIG, USAGE_PERIODS, USAGE_SOURCES } from '@nao/backend/usage';
import type { Granularity, UsagePeriod, UsageSource } from '@nao/backend/usage';
import type {
ChatReplayFeedbackState,
ChatReplayToolState,
Expand All @@ -21,19 +21,15 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { cn } from '@/lib/utils';

type UsagePeriod = '24h' | '15d' | '6m';

const periodOptions: { value: UsagePeriod; label: string; granularity: Granularity }[] = [
{ value: '24h', label: 'Last 24 hours', granularity: 'hour' },
{ value: '15d', label: 'Last 15 days', granularity: 'day' },
{ value: '6m', label: 'Last 6 months', granularity: 'month' },
];
export const periodOptions: { value: UsagePeriod; label: string; granularity: Granularity }[] = USAGE_PERIODS.map(
(value) => ({
value,
label: PERIOD_CONFIG[value].label,
granularity: PERIOD_CONFIG[value].granularity,
}),
);

const periodByGranularity: Record<Granularity, UsagePeriod> = {
hour: '24h',
day: '15d',
month: '6m',
};
export const periodByGranularity: Record<Granularity, UsagePeriod> = DEFAULT_PERIOD_BY_GRANULARITY;

export const dateFormats: Record<Granularity, string> = {
hour: 'MMM d, HH:00',
Expand All @@ -45,6 +41,8 @@ interface UsageFiltersProps {
showUsageControls?: boolean;
provider: LlmProvider | 'all';
onProviderChange: (value: LlmProvider | 'all') => void;
period?: UsagePeriod;
onPeriodChange?: (period: UsagePeriod, granularity: Granularity) => void;
granularity: Granularity;
onGranularityChange: (value: Granularity) => void;
availableProviders: LlmProvider[] | undefined;
Expand All @@ -59,6 +57,8 @@ export function UsageFilters({
showUsageControls = true,
provider,
onProviderChange,
period: passedPeriod,
onPeriodChange,
granularity,
onGranularityChange,
availableProviders,
Expand All @@ -68,7 +68,10 @@ export function UsageFilters({
selectedSources,
onSelectedSourcesChange,
}: UsageFiltersProps) {
const period = periodByGranularity[granularity];
const currentPeriod = passedPeriod ?? periodByGranularity[granularity] ?? '15d';
const availableOptions = onPeriodChange
? periodOptions
: periodOptions.filter((o) => o.value === '24h' || o.value === '15d' || o.value === '6m');
const userOptions = (chatFacets?.userNames ?? []).map((name) => ({
value: name,
label: name,
Expand Down Expand Up @@ -97,19 +100,24 @@ export function UsageFilters({
</SelectContent>
</Select>
<Select
value={period}
value={currentPeriod}
onValueChange={(value) => {
const option = periodOptions.find((o) => o.value === value);
const nextPeriod = value as UsagePeriod;
const option = availableOptions.find((o) => o.value === nextPeriod);
if (option) {
onGranularityChange(option.granularity);
if (onPeriodChange) {
onPeriodChange(option.value, option.granularity);
} else {
onGranularityChange(option.granularity);
}
}
}}
>
<SelectTrigger className='w-40'>
<SelectValue />
</SelectTrigger>
<SelectContent>
{periodOptions.map((option) => (
{availableOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
Expand Down
Loading
Loading