From 3a27f0469892d6b4e60fa591c706726c3728724b Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Fri, 10 Jul 2026 13:33:41 +0200 Subject: [PATCH 1/3] feat: support switch to UTC for usage page --- src/app/dashboard/[teamSlug]/usage/page.tsx | 88 ++++++++------- src/features/dashboard/timezone/context.tsx | 31 +++++- src/features/dashboard/timezone/index.ts | 4 +- src/features/dashboard/timezone/schema.ts | 4 +- .../usage/billing-timezone-banner.tsx | 45 ++++++++ .../usage/redesign/usage-metric-detail.tsx | 4 +- .../dashboard/usage/usage-timezone-utils.ts | 45 ++++++++ .../dashboard/usage/usage-timezone.tsx | 104 ++++++++++++++++++ tests/unit/usage-timezone-utils.test.ts | 99 +++++++++++++++++ 9 files changed, 378 insertions(+), 46 deletions(-) create mode 100644 src/features/dashboard/usage/billing-timezone-banner.tsx create mode 100644 src/features/dashboard/usage/usage-timezone-utils.ts create mode 100644 src/features/dashboard/usage/usage-timezone.tsx create mode 100644 tests/unit/usage-timezone-utils.test.ts 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/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 (
-
+
+
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..3b8ce0cca --- /dev/null +++ b/src/features/dashboard/usage/usage-timezone.tsx @@ -0,0 +1,104 @@ +'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) => { + const now = Date.now() + const reanchoredTimeframe = reanchorTimeframeToTimezone( + { + start: params.start ?? now - INITIAL_TIMEFRAME_FALLBACK_RANGE_MS, + end: params.end ?? now, + }, + resolveUsageTimezone(userTimezone, isPinnedToUtc), + resolveUsageTimezone(userTimezone, pinned) + ) + + void setParams({ + utc: pinned || null, + ...(reanchoredTimeframe ?? {}), + }) + }, + [params.start, params.end, userTimezone, isPinnedToUtc, 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/tests/unit/usage-timezone-utils.test.ts b/tests/unit/usage-timezone-utils.test.ts new file mode 100644 index 000000000..a6f4be3b7 --- /dev/null +++ b/tests/unit/usage-timezone-utils.test.ts @@ -0,0 +1,99 @@ +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 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() + }) +}) From 35133c3b3ce1abbc837d8c256f95e2ed736a9080 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Fri, 10 Jul 2026 14:01:47 +0200 Subject: [PATCH 2/3] fix: prefer closest preset match when re-anchoring usage timeframe Near month ends "this-month" also fell within the loose 24h tolerance of "last-30-days", and findMatchingPreset returned the first match, so pinning to UTC could drop a day from the range. The closest preset now wins, the tolerance is shared between highlight and re-anchoring, and setPinnedToUtc uses a functional URL update so it stays stable across timeframe changes. --- src/features/dashboard/usage/constants.ts | 8 +++ .../usage/usage-time-range-controls.tsx | 7 ++- .../dashboard/usage/usage-timezone-utils.ts | 13 ++--- .../dashboard/usage/usage-timezone.tsx | 28 +++++----- src/lib/utils/time-range.ts | 22 +++++--- tests/unit/time-range.test.ts | 54 +++++++++++++++++++ tests/unit/usage-timezone-utils.test.ts | 27 ++++++++++ 7 files changed, 131 insertions(+), 28 deletions(-) create mode 100644 tests/unit/time-range.test.ts 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/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index f17590951..f1bd00a79 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -17,7 +17,10 @@ import { import { Separator } from '@/ui/primitives/separator' import { TimeRangePicker } from '@/ui/time-range-picker' import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets' -import { getUsageTimeRangePresets } from './constants' +import { + getUsageTimeRangePresets, + PRESET_MATCH_TOLERANCE_MS, +} from './constants' import { determineSamplingMode, normalizeToEndOfSamplingPeriod, @@ -56,7 +59,7 @@ export function UsageTimeRangeControls({ timeRangePresets, timeframe.start, timeframe.end, - 1000 * 60 * 60 * 24 // 1 day in tolerance + PRESET_MATCH_TOLERANCE_MS ), [timeRangePresets, timeframe.start, timeframe.end] ) diff --git a/src/features/dashboard/usage/usage-timezone-utils.ts b/src/features/dashboard/usage/usage-timezone-utils.ts index 224207296..a9a5ccdb5 100644 --- a/src/features/dashboard/usage/usage-timezone-utils.ts +++ b/src/features/dashboard/usage/usage-timezone-utils.ts @@ -1,9 +1,9 @@ import { type Timezone, UTC_TIMEZONE } from '@/features/dashboard/timezone' import { findMatchingPreset } from '@/lib/utils/time-range' -import { getUsageTimeRangePresets } from './constants' - -// Same tolerance the time-range controls use to highlight the active preset. -const PRESET_MATCH_TOLERANCE_MS = 24 * 60 * 60 * 1000 +import { + getUsageTimeRangePresets, + PRESET_MATCH_TOLERANCE_MS, +} from './constants' export function resolveUsageTimezone( userTimezone: Timezone, @@ -15,8 +15,9 @@ export function resolveUsageTimezone( /** * 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. Returns null for custom - * ranges, which are absolute instants and must be kept as picked. + * 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 }, diff --git a/src/features/dashboard/usage/usage-timezone.tsx b/src/features/dashboard/usage/usage-timezone.tsx index 3b8ce0cca..5af7abb98 100644 --- a/src/features/dashboard/usage/usage-timezone.tsx +++ b/src/features/dashboard/usage/usage-timezone.tsx @@ -53,22 +53,24 @@ export function UsageTimezoneProvider({ children }: { children: ReactNode }) { const setPinnedToUtc = useCallback( (pinned: boolean) => { - const now = Date.now() - const reanchoredTimeframe = reanchorTimeframeToTimezone( - { - start: params.start ?? now - INITIAL_TIMEFRAME_FALLBACK_RANGE_MS, - end: params.end ?? now, - }, - resolveUsageTimezone(userTimezone, isPinnedToUtc), - resolveUsageTimezone(userTimezone, pinned) - ) + 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) + ) - void setParams({ - utc: pinned || null, - ...(reanchoredTimeframe ?? {}), + return { + utc: pinned || null, + ...(reanchoredTimeframe ?? {}), + } }) }, - [params.start, params.end, userTimezone, isPinnedToUtc, setParams] + [userTimezone, setParams] ) const effectiveTimezone = resolveUsageTimezone(userTimezone, isPinnedToUtc) 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 index a6f4be3b7..3d8154b0c 100644 --- a/tests/unit/usage-timezone-utils.test.ts +++ b/tests/unit/usage-timezone-utils.test.ts @@ -96,4 +96,31 @@ describe('reanchorTimeframeToTimezone', () => { ) ).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') + ) + }) + + 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) + }) }) From abc31f4c76e5b9cb302a3234c7ce8fec6a9d3ebf Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Fri, 10 Jul 2026 15:25:10 +0200 Subject: [PATCH 3/3] fix: resolve identical preset ranges to the calendar preset when re-anchoring On the 30th of a 30-day month "this-month" and "last-30-days" produce identical ranges, so the re-anchor match is a pure tie. When the user's timezone is a calendar day ahead of UTC, guessing the rolling preset rewrote the pinned view to a window missing the billing month. The re-anchor path now matches calendar-anchored presets first; the popover order and highlight behavior are unchanged. --- .../dashboard/usage/usage-timezone-utils.ts | 17 ++++++++++++++++- tests/unit/usage-timezone-utils.test.ts | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/features/dashboard/usage/usage-timezone-utils.ts b/src/features/dashboard/usage/usage-timezone-utils.ts index a9a5ccdb5..b1d44192f 100644 --- a/src/features/dashboard/usage/usage-timezone-utils.ts +++ b/src/features/dashboard/usage/usage-timezone-utils.ts @@ -1,10 +1,25 @@ import { type Timezone, UTC_TIMEZONE } from '@/features/dashboard/timezone' import { findMatchingPreset } from '@/lib/utils/time-range' +import type { TimeRangePreset } from '@/ui/time-range-presets' import { getUsageTimeRangePresets, PRESET_MATCH_TOLERANCE_MS, } from './constants' +const CALENDAR_ANCHORED_PRESET_IDS = new Set([ + 'this-month', + 'last-month', + 'this-year', + 'last-year', +]) + +// Identical ranges (e.g. "this-month" vs "last-30-days" on the 30th of a +// 30-day month) must resolve to the calendar preset, or pinning to UTC can +// re-anchor to a rolling window that misses the billing month. +const byCalendarAnchoredFirst = (a: TimeRangePreset, b: TimeRangePreset) => + Number(CALENDAR_ANCHORED_PRESET_IDS.has(b.id)) - + Number(CALENDAR_ANCHORED_PRESET_IDS.has(a.id)) + export function resolveUsageTimezone( userTimezone: Timezone, isPinnedToUtc: boolean @@ -25,7 +40,7 @@ export function reanchorTimeframeToTimezone( toTimezone: Timezone ): { start: number; end: number } | null { const matchedPresetId = findMatchingPreset( - getUsageTimeRangePresets(fromTimezone), + getUsageTimeRangePresets(fromTimezone).toSorted(byCalendarAnchoredFirst), timeframe.start, timeframe.end, PRESET_MATCH_TOLERANCE_MS diff --git a/tests/unit/usage-timezone-utils.test.ts b/tests/unit/usage-timezone-utils.test.ts index 3d8154b0c..d8c820823 100644 --- a/tests/unit/usage-timezone-utils.test.ts +++ b/tests/unit/usage-timezone-utils.test.ts @@ -11,6 +11,7 @@ import { } from '@/features/dashboard/usage/usage-timezone-utils' const prague = TimezoneSchema.parse('Europe/Prague') +const tokyo = TimezoneSchema.parse('Asia/Tokyo') const getPresetRange = ( timezone: typeof prague, @@ -113,6 +114,22 @@ describe('reanchorTimeframeToTimezone', () => { ) }) + // 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'))