diff --git a/apps/docs/cost-analytics.mdx b/apps/docs/cost-analytics.mdx index 0a2a3dc27d..b0b1b874a4 100644 --- a/apps/docs/cost-analytics.mdx +++ b/apps/docs/cost-analytics.mdx @@ -34,6 +34,11 @@ model and provider. The source also appears in the detail drilldown. Choose a time range before comparing periods so the chart and breakdown use the same window. +Analytics date presets and daily chart buckets use the web server's timezone, +including daylight-saving transitions. Costs, Tasks, and PR analytics use the +same calendar-day boundaries; these are not based on each viewer's browser +timezone or the deployment's automation scheduling timezone. + ## Exporting data Select **Download data** to export the currently filtered detail rows as a CSV diff --git a/apps/web/src/lib/server/analytics/time-buckets.ts b/apps/web/src/lib/server/analytics/time-buckets.ts index 88c6e70076..92d353e561 100644 --- a/apps/web/src/lib/server/analytics/time-buckets.ts +++ b/apps/web/src/lib/server/analytics/time-buckets.ts @@ -35,14 +35,7 @@ export function getRequestTimeBootstrapCutoff( timePeriod: TimePeriodFilter | undefined, now: Date, ): Date | null { - if (!timePeriod || timePeriod === 'all') { - return null; - } - - const cutoff = new Date(now); - cutoff.setUTCHours(0, 0, 0, 0); - cutoff.setUTCDate(cutoff.getUTCDate() - (timePeriod - 1)); - return cutoff; + return getTimeCutoff(timePeriod, now); } export function getResolvedGranularity( diff --git a/apps/web/src/lib/server/pull-request-facts.test.ts b/apps/web/src/lib/server/pull-request-facts.test.ts new file mode 100644 index 0000000000..4a07c37350 --- /dev/null +++ b/apps/web/src/lib/server/pull-request-facts.test.ts @@ -0,0 +1,96 @@ +import { + db, + eq, + pullRequestFacts, + repositories, + repositoryFactory, + userFactory, + users, +} from '@roomote/db/server'; + +import { getRequestTimeBootstrapCutoff } from './analytics/time-buckets'; +import { getStoredPullRequestsForAnalytics } from './pull-request-facts'; + +const cases = [ + ['America/Los_Angeles', '2026-09-07T02:00:00Z', '2026-09-06T07:00:00Z'], + ['Asia/Tokyo', '2026-09-07T02:00:00Z', '2026-09-06T15:00:00Z'], + ['America/Los_Angeles', '2026-03-08T12:00:00Z', '2026-03-08T08:00:00Z'], + ['America/Los_Angeles', '2025-11-02T12:00:00Z', '2025-11-02T07:00:00Z'], +]; + +describe('PR analytics calendar-day cutoffs', () => { + let repositoryId: string; + let userId: string; + + beforeAll(async () => { + const user = await userFactory.create(); + userId = user.id; + const repository = await repositoryFactory.create({ + sourceControlProvider: 'gitea', + linkedByUserId: userId, + }); + repositoryId = repository.id; + }); + + afterEach(async () => { + vi.unstubAllEnvs(); + await db + .delete(pullRequestFacts) + .where(eq(pullRequestFacts.repositoryId, repositoryId)); + }); + + afterAll(async () => { + await db.delete(repositories).where(eq(repositories.id, repositoryId)); + await db.delete(users).where(eq(users.id, userId)); + }); + + it.each(cases)( + 'filters stored PRs at local midnight in %s at %s', + async (tz, timestamp, cutoff) => { + vi.stubEnv('TZ', tz!); + const start = new Date(cutoff!); + await db.insert(pullRequestFacts).values( + [-1, 0, 3600000].map((offset, index) => ({ + repositoryId, + repositoryFullName: 'fixture/calendar-days', + sourceControlProvider: 'gitea' as const, + externalPullRequestId: index + 1, + prNumber: index + 1, + title: `Boundary ${index}`, + htmlUrl: `https://example.invalid/pulls/${index + 1}`, + state: 'open' as const, + createdAtRemote: new Date(start.getTime() + offset), + updatedAtRemote: new Date(start.getTime() + offset), + })), + ); + + const rows = await getStoredPullRequestsForAnalytics({ + repositoryIds: [repositoryId], + timePeriod: 1, + now: new Date(timestamp!), + }); + expect(rows.map((row) => row.number).sort()).toEqual([2, 3]); + const allRows = await getStoredPullRequestsForAnalytics({ + repositoryIds: [repositoryId], + timePeriod: 'all', + now: new Date(timestamp!), + }); + expect(allRows).toHaveLength(3); + }, + ); + + it.each(cases)( + 'bootstraps the same local day in %s at %s', + (tz, timestamp, cutoff) => { + vi.stubEnv('TZ', tz!); + expect(getRequestTimeBootstrapCutoff(1, new Date(timestamp!))).toEqual( + new Date(cutoff!), + ); + }, + ); + + it('does not bound an all-time or unspecified bootstrap', () => { + expect(getRequestTimeBootstrapCutoff('all', new Date())).toBeNull(); + expect(getRequestTimeBootstrapCutoff(undefined, new Date())).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/server/pull-request-facts.ts b/apps/web/src/lib/server/pull-request-facts.ts index 465b63c9c0..8f9ef50e06 100644 --- a/apps/web/src/lib/server/pull-request-facts.ts +++ b/apps/web/src/lib/server/pull-request-facts.ts @@ -10,20 +10,7 @@ import { } from '@roomote/db/server'; import type { TimePeriodFilter } from '@/types'; - -function getTimeCutoff( - timePeriod: TimePeriodFilter | undefined, - now: Date, -): Date | null { - if (!timePeriod || timePeriod === 'all') { - return null; - } - - const cutoff = new Date(now); - cutoff.setUTCHours(0, 0, 0, 0); - cutoff.setUTCDate(cutoff.getUTCDate() - (timePeriod - 1)); - return cutoff; -} +import { getTimeCutoff } from './analytics/time-buckets'; export async function getStoredPullRequestsForAnalytics(params: { repositoryIds: string[];