-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
+
+
)
}
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)
+ })
+})