diff --git a/src/app/dashboard/[teamSlug]/usage/page.tsx b/src/app/dashboard/[teamSlug]/usage/page.tsx index a39ee77d8..2ae97b742 100644 --- a/src/app/dashboard/[teamSlug]/usage/page.tsx +++ b/src/app/dashboard/[teamSlug]/usage/page.tsx @@ -4,10 +4,12 @@ import { featureFlags } from '@/core/modules/feature-flags/feature-flags.server' import { getAuthContext } from '@/core/server/auth' import { getTeamIdFromSlug } from '@/core/server/functions/team/get-team-id-from-slug' import { getUsage } from '@/core/server/functions/usage/get-usage' +import { BillingTimezoneBanner } from '@/features/dashboard/usage/billing-timezone-banner' import { EMPTY_USAGE } from '@/features/dashboard/usage/redesign/usage-fallback' import { UsageMetricDetail } from '@/features/dashboard/usage/redesign/usage-metric-detail' import { UsageChartsProvider } from '@/features/dashboard/usage/usage-charts-context' import { UsageMetricChart } from '@/features/dashboard/usage/usage-metric-chart' +import { UsageTimezoneProvider } from '@/features/dashboard/usage/usage-timezone' import { UsageTopTimeRangeControls } from '@/features/dashboard/usage/usage-top-time-range-controls' import ErrorBoundary from '@/ui/error' import Frame from '@/ui/frame' @@ -44,9 +46,11 @@ export default async function UsagePage({ // when usage can't be loaded instead of erroring the whole screen. if (newUsagePage) { return ( - - - + + + + + ) } @@ -65,43 +69,47 @@ export default async function UsagePage({ } return ( - -
-
- -
- -
-
- - - - -
- + + +
+
+ + +
+ + +
+
+ + + + +
+ +
-
- + + ) } diff --git a/src/features/dashboard/timezone/context.tsx b/src/features/dashboard/timezone/context.tsx index 8e39e8f14..dcc427727 100644 --- a/src/features/dashboard/timezone/context.tsx +++ b/src/features/dashboard/timezone/context.tsx @@ -10,10 +10,10 @@ import { useMemo, useState, } from 'react' -import { type Timezone, TimezoneSchema } from './schema' +import { type Timezone, UTC_TIMEZONE } from './schema' import { getBrowserTimezone, parseTimezone } from './utils' -const DEFAULT_TIMEZONE = TimezoneSchema.parse('UTC') +const DEFAULT_TIMEZONE = UTC_TIMEZONE interface TimezoneContextValue { timezone: Timezone @@ -99,6 +99,33 @@ export const TimezoneProvider = ({ ) } +interface TimezoneOverrideProps { + children: ReactNode + timezone: Timezone +} + +const setTimezoneNoop = async () => false + +/** + * Pins `useTimezone()` to a fixed timezone for a subtree without touching the + * persisted preference. `setTimezone` is disabled under the override. + */ +export const TimezoneOverride = ({ + children, + timezone, +}: TimezoneOverrideProps) => { + const value = useMemo( + () => ({ timezone, setTimezone: setTimezoneNoop }), + [timezone] + ) + + return ( + + {children} + + ) +} + export const useTimezone = () => { const context = useContext(TimezoneContext) if (!context) { diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index 62e32926f..c2d407cb4 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -1,6 +1,6 @@ -export { TimezoneProvider, useTimezone } from './context' +export { TimezoneOverride, TimezoneProvider, useTimezone } from './context' export type { Timezone } from './schema' -export { TimezoneSchema } from './schema' +export { TimezoneSchema, UTC_TIMEZONE } from './schema' export { formatTimezoneLabel, getBrowserTimezone, diff --git a/src/features/dashboard/timezone/schema.ts b/src/features/dashboard/timezone/schema.ts index 3b4e68bcf..d22f6ac42 100644 --- a/src/features/dashboard/timezone/schema.ts +++ b/src/features/dashboard/timezone/schema.ts @@ -18,4 +18,6 @@ const TimezoneSchema = z type Timezone = z.infer -export { TimezoneSchema, type Timezone } +const UTC_TIMEZONE = TimezoneSchema.parse('UTC') + +export { TimezoneSchema, UTC_TIMEZONE, type Timezone } diff --git a/src/features/dashboard/usage/billing-timezone-banner.tsx b/src/features/dashboard/usage/billing-timezone-banner.tsx new file mode 100644 index 000000000..1039dc7b6 --- /dev/null +++ b/src/features/dashboard/usage/billing-timezone-banner.tsx @@ -0,0 +1,45 @@ +'use client' + +import { useMemo } from 'react' +import { cn } from '@/lib/utils' +import { formatTimezoneAbbreviation } from '@/lib/utils/formatting' +import { Button } from '@/ui/primitives/button' +import { InfoIcon } from '@/ui/primitives/icons' +import { useUsageTimezone } from './usage-timezone' +import { isBillingTimezoneBannerVisible } from './usage-timezone-utils' + +export function BillingTimezoneBanner({ className }: { className?: string }) { + const { userTimezone, isPinnedToUtc, setPinnedToUtc } = useUsageTimezone() + + const userTimezoneAbbreviation = useMemo( + () => formatTimezoneAbbreviation(new Date(), userTimezone), + [userTimezone] + ) + + if (!isBillingTimezoneBannerVisible(userTimezone)) { + return null + } + + return ( +
+ + + {`Billing uses UTC, which differs from your default timezone (${userTimezoneAbbreviation}).`} + + +
+ ) +} diff --git a/src/features/dashboard/usage/constants.ts b/src/features/dashboard/usage/constants.ts index 227ddb81d..62c4160d8 100644 --- a/src/features/dashboard/usage/constants.ts +++ b/src/features/dashboard/usage/constants.ts @@ -23,6 +23,14 @@ export const INITIAL_TIMEFRAME_FALLBACK_RANGE_MS = 30 * 24 * 60 * 60 * 1000 export const HOURLY_SAMPLING_THRESHOLD_DAYS = 3 export const WEEKLY_SAMPLING_THRESHOLD_DAYS = 60 +/** + * How far a timeframe may deviate from a preset's boundaries and still be + * treated as that preset. Shared between the time-range controls (preset + * highlight) and the UTC pin (timeframe re-anchoring) — the two must not + * drift, or a highlighted preset would fail to re-anchor. + */ +export const PRESET_MATCH_TOLERANCE_MS = 24 * 60 * 60 * 1000 + type CalendarDateParts = ReturnType const shiftToMonthStart = ( diff --git a/src/features/dashboard/usage/redesign/usage-metric-detail.tsx b/src/features/dashboard/usage/redesign/usage-metric-detail.tsx index 5611c969e..ecaa62546 100644 --- a/src/features/dashboard/usage/redesign/usage-metric-detail.tsx +++ b/src/features/dashboard/usage/redesign/usage-metric-detail.tsx @@ -4,6 +4,7 @@ import { useId, useState } from 'react' import { cn } from '@/lib/utils' import { ChevronDownIcon, CpuIcon } from '@/ui/primitives/icons' import { Tabs, TabsList, TabsTrigger } from '@/ui/primitives/tabs' +import { BillingTimezoneBanner } from '../billing-timezone-banner' import { useUsageCharts } from '../usage-charts-context' import { UsageTopTimeRangeControls } from '../usage-top-time-range-controls' import { USAGE_METRICS, type UsageMetricKey } from './metrics' @@ -38,8 +39,9 @@ export function UsageMetricDetail({ metric }: { metric: UsageMetricKey }) { return (
-
+
+
+ Number(CALENDAR_ANCHORED_PRESET_IDS.has(b.id)) - + Number(CALENDAR_ANCHORED_PRESET_IDS.has(a.id)) + +export function resolveUsageTimezone( + userTimezone: Timezone, + isPinnedToUtc: boolean +): Timezone { + return isPinnedToUtc ? UTC_TIMEZONE : userTimezone +} + +/** + * A preset-aligned timeframe describes calendar boundaries ("This month"), so + * switching timezone re-anchors it to the new timezone's boundaries — that is + * what makes totals match billing when pinning to UTC. Ranges within the + * highlight tolerance of a preset are treated as that preset; only genuinely + * custom ranges return null and are kept as picked. + */ +export function reanchorTimeframeToTimezone( + timeframe: { start: number; end: number }, + fromTimezone: Timezone, + toTimezone: Timezone +): { start: number; end: number } | null { + const matchedPresetId = findMatchingPreset( + getUsageTimeRangePresets(fromTimezone).toSorted(byCalendarAnchoredFirst), + timeframe.start, + timeframe.end, + PRESET_MATCH_TOLERANCE_MS + ) + if (!matchedPresetId) return null + + const preset = getUsageTimeRangePresets(toTimezone).find( + (candidate) => candidate.id === matchedPresetId + ) + + return preset?.getValue() ?? null +} + +export function isBillingTimezoneBannerVisible( + userTimezone: Timezone +): boolean { + return userTimezone !== UTC_TIMEZONE +} diff --git a/src/features/dashboard/usage/usage-timezone.tsx b/src/features/dashboard/usage/usage-timezone.tsx new file mode 100644 index 000000000..5af7abb98 --- /dev/null +++ b/src/features/dashboard/usage/usage-timezone.tsx @@ -0,0 +1,106 @@ +'use client' + +import { parseAsBoolean, parseAsInteger, useQueryStates } from 'nuqs' +import { + createContext, + type ReactNode, + useCallback, + useContext, + useMemo, +} from 'react' +import { + type Timezone, + TimezoneOverride, + useTimezone, +} from '@/features/dashboard/timezone' +import { INITIAL_TIMEFRAME_FALLBACK_RANGE_MS } from './constants' +import { + reanchorTimeframeToTimezone, + resolveUsageTimezone, +} from './usage-timezone-utils' + +interface UsageTimezoneContextValue { + userTimezone: Timezone + effectiveTimezone: Timezone + isPinnedToUtc: boolean + setPinnedToUtc: (pinned: boolean) => void +} + +const UsageTimezoneContext = createContext( + null +) + +/** + * Billing meters and invoices are aggregated over UTC boundaries, so the usage + * page offers a page-scoped UTC pin (via the `utc` query param) that shadows + * the dashboard timezone preference without persisting anything. + */ +export function UsageTimezoneProvider({ children }: { children: ReactNode }) { + const { timezone: userTimezone } = useTimezone() + + // `start`/`end` mirror the timeframe params owned by usage-charts-context; + // sharing one useQueryStates call lets a pin flip re-anchor them atomically. + const [params, setParams] = useQueryStates( + { + utc: parseAsBoolean.withDefault(false), + start: parseAsInteger, + end: parseAsInteger, + }, + { history: 'push', shallow: true } + ) + + const isPinnedToUtc = params.utc + + const setPinnedToUtc = useCallback( + (pinned: boolean) => { + void setParams((prev) => { + const now = Date.now() + const reanchoredTimeframe = reanchorTimeframeToTimezone( + { + start: prev.start ?? now - INITIAL_TIMEFRAME_FALLBACK_RANGE_MS, + end: prev.end ?? now, + }, + resolveUsageTimezone(userTimezone, prev.utc), + resolveUsageTimezone(userTimezone, pinned) + ) + + return { + utc: pinned || null, + ...(reanchoredTimeframe ?? {}), + } + }) + }, + [userTimezone, setParams] + ) + + const effectiveTimezone = resolveUsageTimezone(userTimezone, isPinnedToUtc) + + const value = useMemo( + () => ({ + userTimezone, + effectiveTimezone, + isPinnedToUtc, + setPinnedToUtc, + }), + [userTimezone, effectiveTimezone, isPinnedToUtc, setPinnedToUtc] + ) + + return ( + + + {children} + + + ) +} + +export function useUsageTimezone() { + const context = useContext(UsageTimezoneContext) + if (!context) { + throw new Error( + 'useUsageTimezone must be used within UsageTimezoneProvider' + ) + } + + return context +} diff --git a/src/lib/utils/time-range.ts b/src/lib/utils/time-range.ts index cf2f4663f..9eea2f0bb 100644 --- a/src/lib/utils/time-range.ts +++ b/src/lib/utils/time-range.ts @@ -1,7 +1,9 @@ import type { TimeRangePreset } from '@/ui/time-range-presets' /** - * Finds which preset matches the given timeframe (with tolerance) + * Finds which preset matches the given timeframe (with tolerance). + * Presets can overlap within the tolerance (e.g. "This month" vs "Last 30 + * days" near month ends), so the closest match wins, not the first one. */ export function findMatchingPreset( presets: TimeRangePreset[], @@ -9,16 +11,22 @@ export function findMatchingPreset( end: number, toleranceMs = 10 * 1000 // 10 seconds ): string | undefined { + let bestId: string | undefined + let bestDeviation = Infinity + for (const preset of presets) { const { start: presetStart, end: presetEnd } = preset.getValue() + const startDiff = Math.abs(start - presetStart) + const endDiff = Math.abs(end - presetEnd) + + if (startDiff > toleranceMs || endDiff > toleranceMs) continue - if ( - Math.abs(start - presetStart) <= toleranceMs && - Math.abs(end - presetEnd) <= toleranceMs - ) { - return preset.id + const deviation = startDiff + endDiff + if (deviation < bestDeviation) { + bestDeviation = deviation + bestId = preset.id } } - return undefined + return bestId } diff --git a/tests/unit/time-range.test.ts b/tests/unit/time-range.test.ts new file mode 100644 index 000000000..f34894c37 --- /dev/null +++ b/tests/unit/time-range.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { findMatchingPreset } from '@/lib/utils/time-range' +import type { TimeRangePreset } from '@/ui/time-range-presets' + +const DAY_MS = 24 * 60 * 60 * 1000 + +const makePreset = ( + id: string, + start: number, + end: number +): TimeRangePreset => ({ + id, + label: id, + getValue: () => ({ start, end }), +}) + +describe('findMatchingPreset', () => { + it('matches a preset within tolerance', () => { + const presets = [makePreset('a', 0, 10 * DAY_MS)] + + expect(findMatchingPreset(presets, 1000, 10 * DAY_MS - 1000, 5000)).toBe( + 'a' + ) + }) + + it('returns undefined when no preset is within tolerance', () => { + const presets = [makePreset('a', 0, 10 * DAY_MS)] + + expect( + findMatchingPreset(presets, 2 * DAY_MS, 12 * DAY_MS, 5000) + ).toBeUndefined() + }) + + it('prefers the closest preset over an earlier loose overlap', () => { + const exact = { start: 0, end: 31 * DAY_MS } + const presets = [ + makePreset('loose', exact.start + DAY_MS, exact.end), + makePreset('exact', exact.start, exact.end), + ] + + expect(findMatchingPreset(presets, exact.start, exact.end, DAY_MS)).toBe( + 'exact' + ) + }) + + it('breaks deviation ties by preset order', () => { + const presets = [ + makePreset('first', 0, 10 * DAY_MS), + makePreset('second', 0, 10 * DAY_MS), + ] + + expect(findMatchingPreset(presets, 0, 10 * DAY_MS, DAY_MS)).toBe('first') + }) +}) diff --git a/tests/unit/usage-timezone-utils.test.ts b/tests/unit/usage-timezone-utils.test.ts new file mode 100644 index 000000000..d8c820823 --- /dev/null +++ b/tests/unit/usage-timezone-utils.test.ts @@ -0,0 +1,143 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + TimezoneSchema, + UTC_TIMEZONE, +} from '@/features/dashboard/timezone/schema' +import { getUsageTimeRangePresets } from '@/features/dashboard/usage/constants' +import { + isBillingTimezoneBannerVisible, + reanchorTimeframeToTimezone, + resolveUsageTimezone, +} from '@/features/dashboard/usage/usage-timezone-utils' + +const prague = TimezoneSchema.parse('Europe/Prague') +const tokyo = TimezoneSchema.parse('Asia/Tokyo') + +const getPresetRange = ( + timezone: typeof prague, + presetId: string +): { start: number; end: number } => { + const preset = getUsageTimeRangePresets(timezone).find( + (option) => option.id === presetId + ) + if (!preset) throw new Error(`Expected ${presetId} preset to exist`) + + return preset.getValue() +} + +describe('resolveUsageTimezone', () => { + it('returns the user timezone when not pinned to UTC', () => { + expect(resolveUsageTimezone(prague, false)).toBe(prague) + expect(resolveUsageTimezone(UTC_TIMEZONE, false)).toBe(UTC_TIMEZONE) + }) + + it('returns UTC when pinned', () => { + expect(resolveUsageTimezone(prague, true)).toBe(UTC_TIMEZONE) + expect(resolveUsageTimezone(UTC_TIMEZONE, true)).toBe(UTC_TIMEZONE) + }) +}) + +describe('isBillingTimezoneBannerVisible', () => { + it('shows the banner for non-UTC user timezones', () => { + expect(isBillingTimezoneBannerVisible(prague)).toBe(true) + }) + + it('hides the banner when the user timezone is already UTC', () => { + expect(isBillingTimezoneBannerVisible(UTC_TIMEZONE)).toBe(false) + }) +}) + +describe('reanchorTimeframeToTimezone', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-07-10T10:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + const reanchorOrThrow = ( + ...args: Parameters + ) => { + const reanchored = reanchorTimeframeToTimezone(...args) + if (!reanchored) throw new Error('Expected timeframe to re-anchor') + + return reanchored + } + + it('re-anchors a preset-aligned range to the target timezone boundaries', () => { + const pragueThisMonth = getPresetRange(prague, 'this-month') + + const reanchored = reanchorOrThrow(pragueThisMonth, prague, UTC_TIMEZONE) + + expect(reanchored).toEqual(getPresetRange(UTC_TIMEZONE, 'this-month')) + // Prague is UTC+2 in July, so the UTC month starts 2 hours later. + expect(reanchored.start - pragueThisMonth.start).toBe(2 * 60 * 60 * 1000) + }) + + it('round-trips back to the original boundaries', () => { + const pragueThisMonth = getPresetRange(prague, 'this-month') + const utcThisMonth = reanchorOrThrow(pragueThisMonth, prague, UTC_TIMEZONE) + + expect( + reanchorTimeframeToTimezone(utcThisMonth, UTC_TIMEZONE, prague) + ).toEqual(pragueThisMonth) + }) + + it('returns null for custom ranges so they are kept as picked', () => { + const twoWeeks = 14 * 24 * 60 * 60 * 1000 + const customEnd = new Date('2026-06-20T13:37:00.000Z').getTime() + + expect( + reanchorTimeframeToTimezone( + { start: customEnd - twoWeeks, end: customEnd }, + prague, + UTC_TIMEZONE + ) + ).toBeNull() + }) + + // Near month ends "this-month" also falls within the loose match tolerance + // of "last-30-days"; the exact preset must win or a day of usage would be + // dropped from the re-anchored range. + it.each([ + '2026-07-30T10:00:00.000Z', + '2026-07-31T10:00:00.000Z', + ])('keeps "this-month" boundaries when re-anchoring at %s', (systemTime) => { + vi.setSystemTime(new Date(systemTime)) + + const pragueThisMonth = getPresetRange(prague, 'this-month') + + expect(reanchorOrThrow(pragueThisMonth, prague, UTC_TIMEZONE)).toEqual( + getPresetRange(UTC_TIMEZONE, 'this-month') + ) + }) + + // On the 30th of a 30-day month "this-month" and "last-30-days" are + // identical, so the match is a pure tie; the calendar preset must win. With + // Tokyo a calendar day ahead of UTC, guessing "last-30-days" would re-anchor + // to UTC May 31 - Jun 29 instead of the June billing month. + it('resolves identical-range ties to the calendar preset', () => { + // Tokyo 2026-06-30 00:30, UTC date still June 29. + vi.setSystemTime(new Date('2026-06-29T15:30:00.000Z')) + + const tokyoThisMonth = getPresetRange(tokyo, 'this-month') + expect(tokyoThisMonth).toEqual(getPresetRange(tokyo, 'last-30-days')) + + expect(reanchorOrThrow(tokyoThisMonth, tokyo, UTC_TIMEZONE)).toEqual( + getPresetRange(UTC_TIMEZONE, 'this-month') + ) + }) + + it('round-trips "this-month" on the last day of a 31-day month', () => { + vi.setSystemTime(new Date('2026-07-31T10:00:00.000Z')) + + const pragueThisMonth = getPresetRange(prague, 'this-month') + const utcThisMonth = reanchorOrThrow(pragueThisMonth, prague, UTC_TIMEZONE) + + expect( + reanchorTimeframeToTimezone(utcThisMonth, UTC_TIMEZONE, prague) + ).toEqual(pragueThisMonth) + }) +})