- setIsFocused(true)}
onBlur={() => setIsFocused(false)}
>
@@ -243,7 +263,7 @@ export const TimePanel = forwardRef(
disabled={!field.value}
isLive={!field.value}
/>
-
+
diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts
index dab7098c8..17773cd9c 100644
--- a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts
+++ b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts
@@ -1,5 +1,6 @@
import { calculateStepForDuration } from '@/features/dashboard/sandboxes/monitoring/utils'
-import { formatDatetimeInput, tryParseDatetime } from '@/lib/utils/formatting'
+import type { Timezone } from '@/features/dashboard/timezone'
+import { formatDateTimeInput } from '@/lib/utils/formatting'
import type { TimeframeState } from '@/lib/utils/timeframe'
import {
CUSTOM_PANEL_HEIGHT,
@@ -7,6 +8,14 @@ import {
TIME_OPTIONS,
} from './constants'
+const formatZonedDateTimeString = (
+ timestamp: number,
+ timezone: Timezone
+): string => {
+ const parts = formatDateTimeInput(timestamp, timezone)
+ return `${parts.date} ${parts.time}`
+}
+
/**
* Find a matching time option for a given duration based on the step size
*/
@@ -40,7 +49,10 @@ export function getDurationFromTimeframe(
* Convert TimeframeState to formatted datetime strings
* Handles both live and static modes with fallback to last hour
*/
-export function formatTimeframeValues(value: TimeframeState) {
+export function formatTimeframeValues(
+ value: TimeframeState,
+ timezone: Timezone
+) {
const now = new Date()
let startDateTime: string
@@ -49,17 +61,16 @@ export function formatTimeframeValues(value: TimeframeState) {
if (value.mode === 'live' && value.range) {
const startTime = value.start || now.getTime() - value.range
const endTime = value.end || now.getTime()
-
- startDateTime = formatDatetimeInput(new Date(startTime))
- endDateTime = formatDatetimeInput(new Date(endTime))
+ startDateTime = formatZonedDateTimeString(startTime, timezone)
+ endDateTime = formatZonedDateTimeString(endTime, timezone)
} else if (value.mode === 'static' && value.start && value.end) {
- startDateTime = formatDatetimeInput(new Date(value.start))
- endDateTime = formatDatetimeInput(new Date(value.end))
+ startDateTime = formatZonedDateTimeString(value.start, timezone)
+ endDateTime = formatZonedDateTimeString(value.end, timezone)
} else {
// fallback to last hour when no valid timeframe
const hourAgo = now.getTime() - 60 * 60 * 1000
- startDateTime = formatDatetimeInput(new Date(hourAgo))
- endDateTime = formatDatetimeInput(now)
+ startDateTime = formatZonedDateTimeString(hourAgo, timezone)
+ endDateTime = formatZonedDateTimeString(now.getTime(), timezone)
}
return {
@@ -96,8 +107,3 @@ export function calculatePanelPosition(
return 'right'
}
}
-
-export function parseDateTimeStrings(dateStr: string, timeStr: string) {
- if (!dateStr || !timeStr) return null
- return tryParseDatetime(`${dateStr} ${timeStr}`)
-}
diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/validation.ts b/src/features/dashboard/sandboxes/monitoring/time-picker/validation.ts
index 89f904448..bd6115ba8 100644
--- a/src/features/dashboard/sandboxes/monitoring/time-picker/validation.ts
+++ b/src/features/dashboard/sandboxes/monitoring/time-picker/validation.ts
@@ -3,117 +3,134 @@
*/
import { z } from 'zod'
-import { combineDateTimeStrings } from '@/lib/utils/formatting'
+import type { Timezone } from '@/features/dashboard/timezone'
+import { parsePickerDateTime } from '@/ui/time-range-picker.logic'
import { CLOCK_SKEW_TOLERANCE, MAX_DAYS_AGO, MIN_RANGE_MS } from './constants'
-export const customTimeFormSchema = z
- .object({
- startDate: z.string(),
- startTime: z.string(),
- endDate: z.string().optional(),
- endTime: z.string().optional(),
- endEnabled: z.boolean(),
- })
- .superRefine((data, ctx) => {
- // start date and time are required
- if (!data.startDate || !data.startTime) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Start date and time are required',
- path: ['startDate'],
- })
- return
- }
-
- const startDateTime = combineDateTimeStrings(data.startDate, data.startTime)
-
- if (!startDateTime) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Invalid start date/time',
- path: ['startDate'],
- })
- return
- }
-
- const now = Date.now()
- const startTimestamp = startDateTime.getTime()
-
- // validate start date is not more than 31 days ago
- if (startTimestamp < now - MAX_DAYS_AGO) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Start date cannot be more than 31 days ago',
- path: ['startDate'],
- })
- return
- }
-
- // validate start date is not in the future (with tolerance for clock skew)
- if (startTimestamp > now + CLOCK_SKEW_TOLERANCE) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Start date cannot be in the future',
- path: ['startDate'],
- })
- return
- }
-
- // if end is enabled, validate end time
- if (data.endEnabled && data.endDate && data.endTime) {
- const endDateTime = combineDateTimeStrings(data.endDate, data.endTime)
-
- if (!endDateTime) {
+const createCustomTimeFormSchema = (timezone: Timezone) =>
+ z
+ .object({
+ startDate: z.string(),
+ startTime: z.string(),
+ endDate: z.string().optional(),
+ endTime: z.string().optional(),
+ endEnabled: z.boolean(),
+ })
+ .superRefine((data, ctx) => {
+ // start date and time are required
+ if (!data.startDate || !data.startTime) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'Invalid end date/time',
- path: ['endDate'],
+ message: 'Start date and time are required',
+ path: ['startDate'],
})
return
}
- const endTimestamp = endDateTime.getTime()
+ const startDateTime = parsePickerDateTime(
+ data.startDate,
+ data.startTime,
+ '00:00:00',
+ timezone
+ )
- // Ensure end is after start
- if (endTimestamp <= startTimestamp) {
+ if (!startDateTime) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'End time must be after start time',
- path: ['endDate'],
+ message: 'Invalid start date/time',
+ path: ['startDate'],
})
return
}
- // ensure minimum range
- if (endTimestamp - startTimestamp < MIN_RANGE_MS) {
+ const now = Date.now()
+ const startTimestamp = startDateTime.getTime()
+
+ // validate start date is not more than 31 days ago
+ if (startTimestamp < now - MAX_DAYS_AGO) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'Time range must be at least 1.5 minutes',
- path: ['endDate'],
+ message: 'Start date cannot be more than 31 days ago',
+ path: ['startDate'],
})
return
}
- // ensure end is not in the future (with tolerance)
- if (endTimestamp > now + CLOCK_SKEW_TOLERANCE) {
+ // validate start date is not in the future (with tolerance for clock skew)
+ if (startTimestamp > now + CLOCK_SKEW_TOLERANCE) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
- message: 'End date cannot be in the future',
- path: ['endDate'],
+ message: 'Start date cannot be in the future',
+ path: ['startDate'],
})
return
}
- // ensure range doesn't exceed maximum
- if (endTimestamp - startTimestamp > MAX_DAYS_AGO) {
- ctx.addIssue({
- code: z.ZodIssueCode.custom,
- message: 'Date range cannot exceed 31 days',
- path: ['endDate'],
- })
- return
+ // if end is enabled, validate end time
+ if (data.endEnabled && data.endDate && data.endTime) {
+ const endDateTime = parsePickerDateTime(
+ data.endDate,
+ data.endTime,
+ '23:59:59',
+ timezone
+ )
+
+ if (!endDateTime) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Invalid end date/time',
+ path: ['endDate'],
+ })
+ return
+ }
+
+ const endTimestamp = endDateTime.getTime()
+
+ // Ensure end is after start
+ if (endTimestamp <= startTimestamp) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'End time must be after start time',
+ path: ['endDate'],
+ })
+ return
+ }
+
+ // ensure minimum range
+ if (endTimestamp - startTimestamp < MIN_RANGE_MS) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Time range must be at least 1.5 minutes',
+ path: ['endDate'],
+ })
+ return
+ }
+
+ // ensure end is not in the future (with tolerance)
+ if (endTimestamp > now + CLOCK_SKEW_TOLERANCE) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'End date cannot be in the future',
+ path: ['endDate'],
+ })
+ return
+ }
+
+ // ensure range doesn't exceed maximum
+ if (endTimestamp - startTimestamp > MAX_DAYS_AGO) {
+ ctx.addIssue({
+ code: z.ZodIssueCode.custom,
+ message: 'Date range cannot exceed 31 days',
+ path: ['endDate'],
+ })
+ return
+ }
}
- }
- })
+ })
+
+type CustomTimeFormValues = z.infer<
+ ReturnType
+>
-export type CustomTimeFormValues = z.infer
+export { createCustomTimeFormSchema }
+export type { CustomTimeFormValues }
diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx
index c5a4b9d56..0706e479e 100644
--- a/src/features/dashboard/settings/general/team-info.tsx
+++ b/src/features/dashboard/settings/general/team-info.tsx
@@ -1,6 +1,7 @@
'use client'
import { useDashboard } from '@/features/dashboard/context'
+import { useTimezone } from '@/features/dashboard/timezone'
import { formatDate } from '@/lib/utils/formatting'
import CopyButtonInline from '@/ui/copy-button-inline'
@@ -22,7 +23,8 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => (
export const TeamInfo = () => {
const { team } = useDashboard()
- const createdAt = formatDate(new Date(team.createdAt), 'MMM d, yyyy') ?? '--'
+ const { timezone } = useTimezone()
+ const createdAt = formatDate(team.createdAt, { timezone }) ?? '--'
return (
diff --git a/src/features/dashboard/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx
index 5a32f32f5..f21be4e2f 100644
--- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx
+++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx
@@ -4,9 +4,10 @@ import { usePostHog } from 'posthog-js/react'
import { CLI_GENERATED_KEY_NAME } from '@/configs/api'
import type { TeamAPIKey } from '@/core/modules/keys/models'
import { IdBadge, UserAvatar } from '@/features/dashboard/shared'
+import { useTimezone } from '@/features/dashboard/timezone'
import { defaultSuccessToast, useToast } from '@/lib/hooks/use-toast'
import { cn } from '@/lib/utils'
-import { formatDate, formatUTCTimestamp } from '@/lib/utils/formatting'
+import { formatDate } from '@/lib/utils/formatting'
import { E2BSquareBadge } from '@/ui/brand'
import { Button } from '@/ui/primitives/button'
import { KeyIcon, RemoveIcon } from '@/ui/primitives/icons'
@@ -28,9 +29,10 @@ interface ApiKeysTableRowProps {
export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => {
const posthog = usePostHog()
const { toast } = useToast()
+ const { timezone } = useTimezone()
const addedDate = apiKey.createdAt
- ? (formatDate(new Date(apiKey.createdAt), 'MMM d, yyyy') ?? '—')
+ ? (formatDate(apiKey.createdAt, { timezone }) ?? '—')
: '—'
const maskedKey = formatMaskedApiKey(apiKey)
@@ -78,7 +80,10 @@ export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => {
{lastUsedLabel}
- {formatUTCTimestamp(new Date(lastUsedAt))}
+ {formatDate(lastUsedAt, {
+ timezone,
+ format: 'exact-timestamp',
+ })}
) : (
diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx
index 8779ce4c8..a704d0198 100644
--- a/src/features/dashboard/settings/webhooks/table-row.tsx
+++ b/src/features/dashboard/settings/webhooks/table-row.tsx
@@ -2,9 +2,11 @@
import { Fragment, useState } from 'react'
import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types'
+import { useTimezone } from '@/features/dashboard/timezone'
import { useClipboard } from '@/lib/hooks/use-clipboard'
import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast'
import { cn } from '@/lib/utils'
+import { formatDate } from '@/lib/utils/formatting'
import { Badge } from '@/ui/primitives/badge'
import { Button } from '@/ui/primitives/button'
import {
@@ -187,13 +189,10 @@ const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => {
export const WebhookTableRow = ({ webhook }: WebhookRowProps) => {
const { team } = useDashboard()
+ const { timezone } = useTimezone()
const createdAt = webhook.createdAt
- ? new Date(webhook.createdAt).toLocaleDateString('en-US', {
- month: 'short',
- day: 'numeric',
- year: 'numeric',
- })
+ ? (formatDate(webhook.createdAt, { timezone }) ?? '-')
: '-'
return (
diff --git a/src/features/dashboard/templates/list/table-cells.tsx b/src/features/dashboard/templates/list/table-cells.tsx
index 3fdd5dbf3..8566f4729 100644
--- a/src/features/dashboard/templates/list/table-cells.tsx
+++ b/src/features/dashboard/templates/list/table-cells.tsx
@@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { CellContext } from '@tanstack/react-table'
import { useMemo, useState } from 'react'
import type { DefaultTemplate, Template } from '@/core/modules/templates/models'
+import { useTimezone } from '@/features/dashboard/timezone'
import { useClipboard } from '@/lib/hooks/use-clipboard'
import {
defaultErrorToast,
@@ -11,7 +12,7 @@ import {
useToast,
} from '@/lib/hooks/use-toast'
import { cn } from '@/lib/utils'
-import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting'
+import { formatDateParts } from '@/lib/utils/formatting'
import { useTRPC } from '@/trpc/client'
import { AlertDialog } from '@/ui/alert-dialog'
import { E2BBadge } from '@/ui/brand'
@@ -312,14 +313,15 @@ export function TemplateNameCell({
export function CreatedAtCell({
getValue,
}: CellContext
) {
+ const { timezone } = useTimezone()
const dateValue = getValue() as string
const formattedTimestamp = useMemo(() => {
- return formatLocalLogStyleTimestamp(dateValue, {
- includeSeconds: false,
- includeYear: true,
+ return formatDateParts(dateValue, {
+ timezone,
+ format: 'date-year-time-no-seconds',
})
- }, [dateValue])
+ }, [dateValue, timezone])
return (
) {
+ const { timezone } = useTimezone()
const dateValue = getValue() as string
const formattedTimestamp = useMemo(() => {
- return formatLocalLogStyleTimestamp(dateValue, {
- includeSeconds: false,
- includeYear: true,
+ return formatDateParts(dateValue, {
+ timezone,
+ format: 'date-year-time-no-seconds',
})
- }, [dateValue])
+ }, [dateValue, timezone])
return (
Promise
+}
+
+interface TimezoneProviderProps {
+ children: ReactNode
+ initialTimezone: string | null
+}
+
+const TimezoneContext = createContext(null)
+
+const persistTimezone = async (timezone: Timezone): Promise => {
+ try {
+ const response = await fetch('/api/timezone/state', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ body: JSON.stringify({ timezone }),
+ })
+
+ return response.ok
+ } catch {
+ return false
+ }
+}
+
+export const TimezoneProvider = ({
+ children,
+ initialTimezone,
+}: TimezoneProviderProps) => {
+ const parsedInitialTimezone = useMemo(
+ () => parseTimezone(initialTimezone),
+ [initialTimezone]
+ )
+ const [timezone, setTimezoneState] = useState(
+ parsedInitialTimezone ?? DEFAULT_TIMEZONE
+ )
+
+ useEffect(() => {
+ if (parsedInitialTimezone) return
+
+ const browserTimezone = getBrowserTimezone()
+ setTimezoneState(browserTimezone)
+ void persistTimezone(browserTimezone)
+ }, [parsedInitialTimezone])
+
+ const setTimezone = useCallback(
+ async (nextTimezone: Timezone) => {
+ const previousTimezone = timezone
+ setTimezoneState(nextTimezone)
+
+ const didPersist = await persistTimezone(nextTimezone)
+ if (!didPersist) {
+ setTimezoneState(previousTimezone)
+ return false
+ }
+
+ return true
+ },
+ [timezone]
+ )
+
+ const value = useMemo(
+ () => ({
+ timezone,
+ setTimezone,
+ }),
+ [setTimezone, timezone]
+ )
+
+ return (
+
+ {children}
+
+ )
+}
+
+export const useTimezone = () => {
+ const context = useContext(TimezoneContext)
+ if (!context) {
+ throw new Error('useTimezone must be used within TimezoneProvider')
+ }
+
+ return context
+}
diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts
new file mode 100644
index 000000000..62e32926f
--- /dev/null
+++ b/src/features/dashboard/timezone/index.ts
@@ -0,0 +1,10 @@
+export { TimezoneProvider, useTimezone } from './context'
+export type { Timezone } from './schema'
+export { TimezoneSchema } from './schema'
+export {
+ formatTimezoneLabel,
+ getBrowserTimezone,
+ getTimezones,
+ isValidTimezone,
+ parseTimezone,
+} from './utils'
diff --git a/src/features/dashboard/timezone/schema.ts b/src/features/dashboard/timezone/schema.ts
new file mode 100644
index 000000000..3b4e68bcf
--- /dev/null
+++ b/src/features/dashboard/timezone/schema.ts
@@ -0,0 +1,21 @@
+import { z } from 'zod'
+
+const TimezoneSchema = z
+ .string()
+ .min(1)
+ .refine(
+ (timezone) => {
+ try {
+ new Intl.DateTimeFormat('en-US', { timeZone: timezone })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { message: 'Invalid timezone' }
+ )
+ .brand<'Timezone'>()
+
+type Timezone = z.infer
+
+export { TimezoneSchema, type Timezone }
diff --git a/src/features/dashboard/timezone/utils.ts b/src/features/dashboard/timezone/utils.ts
new file mode 100644
index 000000000..82fac5ebf
--- /dev/null
+++ b/src/features/dashboard/timezone/utils.ts
@@ -0,0 +1,73 @@
+import { type Timezone, TimezoneSchema } from './schema'
+
+const isValidTimezone = (timezone: string): timezone is Timezone =>
+ TimezoneSchema.safeParse(timezone).success
+
+const parseTimezone = (
+ timezone: string | null | undefined
+): Timezone | null => {
+ if (!timezone) return null
+
+ const result = TimezoneSchema.safeParse(timezone)
+ if (!result.success) return null
+
+ return result.data
+}
+
+const getUtcTimezone = (): Timezone => {
+ const utcTimezone = TimezoneSchema.safeParse('UTC')
+ if (utcTimezone.success) return utcTimezone.data
+
+ throw new Error('Unable to resolve UTC timezone')
+}
+
+const getBrowserTimezone = (): Timezone => {
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+ const parsedTimezone = parseTimezone(timezone)
+ if (parsedTimezone) return parsedTimezone
+
+ return getUtcTimezone()
+}
+
+const getTimezones = (): Timezone[] => {
+ const browserTimezone = getBrowserTimezone()
+ const utcTimezone = getUtcTimezone()
+
+ if (typeof Intl.supportedValuesOf === 'function') {
+ const timezones = Intl.supportedValuesOf('timeZone')
+ if (timezones.length > 0) {
+ return Array.from(new Set([utcTimezone, browserTimezone, ...timezones]))
+ .filter(isValidTimezone)
+ .sort()
+ }
+ }
+
+ return Array.from(new Set([utcTimezone, browserTimezone])).sort()
+}
+
+const formatTimezoneDisplayName = (timezone: Timezone): string =>
+ timezone.replaceAll('_', ' ')
+
+const formatTimezoneLabel = (timezone: Timezone): string => {
+ const formatter = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ timeZoneName: 'short',
+ })
+
+ const timezoneName = formatter
+ .formatToParts(new Date())
+ .find((part) => part.type === 'timeZoneName')?.value
+
+ const displayName = formatTimezoneDisplayName(timezone)
+ if (!timezoneName) return displayName
+
+ return `${displayName} (${timezoneName})`
+}
+
+export {
+ formatTimezoneLabel,
+ getBrowserTimezone,
+ getTimezones,
+ isValidTimezone,
+ parseTimezone,
+}
diff --git a/src/features/dashboard/usage/constants.ts b/src/features/dashboard/usage/constants.ts
index 91c7441d6..227ddb81d 100644
--- a/src/features/dashboard/usage/constants.ts
+++ b/src/features/dashboard/usage/constants.ts
@@ -1,4 +1,10 @@
-import { formatAxisNumber } from '@/lib/utils/formatting'
+import type { Timezone } from '@/features/dashboard/timezone'
+import {
+ dateTimePartsToUtcTimestamp,
+ formatAxisNumber,
+ getDateParts,
+ shiftCalendarDays,
+} from '@/lib/utils/formatting'
import type { TimeRangePreset } from '@/ui/time-range-presets'
import type {
ComputeChartConfig,
@@ -17,18 +23,69 @@ 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
-export const TIME_RANGE_PRESETS: TimeRangePreset[] = [
+type CalendarDateParts = ReturnType
+
+const shiftToMonthStart = (
+ parts: CalendarDateParts,
+ monthOffset: number
+): CalendarDateParts => {
+ const date = new Date(Date.UTC(parts.year, parts.month - 1 + monthOffset, 1))
+
+ return {
+ year: date.getUTCFullYear(),
+ month: date.getUTCMonth() + 1,
+ day: 1,
+ }
+}
+
+const getMonthEnd = (year: number, month: number): CalendarDateParts => {
+ const date = new Date(Date.UTC(year, month, 0))
+
+ return {
+ year: date.getUTCFullYear(),
+ month: date.getUTCMonth() + 1,
+ day: date.getUTCDate(),
+ }
+}
+
+const getZonedDayBoundaryTimestamps = (
+ startParts: CalendarDateParts,
+ endParts: CalendarDateParts,
+ timezone: Timezone
+): { start: number; end: number } => ({
+ start: dateTimePartsToUtcTimestamp(
+ {
+ ...startParts,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ },
+ timezone
+ ),
+ end:
+ dateTimePartsToUtcTimestamp(
+ {
+ ...endParts,
+ hours: 23,
+ minutes: 59,
+ seconds: 59,
+ },
+ timezone
+ ) + 999,
+})
+
+const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [
{
id: 'last-7-days',
label: 'Last 7 days',
shortcut: '7D',
getValue: () => {
- const end = new Date()
- end.setHours(23, 59, 59, 999)
- const start = new Date(end)
- start.setDate(start.getDate() - 6)
- start.setHours(0, 0, 0, 0)
- return { start: start.getTime(), end: end.getTime() }
+ const today = getDateParts(new Date(), timezone)
+ return getZonedDayBoundaryTimestamps(
+ shiftCalendarDays(today, -6),
+ today,
+ timezone
+ )
},
},
{
@@ -36,12 +93,12 @@ export const TIME_RANGE_PRESETS: TimeRangePreset[] = [
label: 'Last 14 days',
shortcut: '14D',
getValue: () => {
- const end = new Date()
- end.setHours(23, 59, 59, 999)
- const start = new Date(end)
- start.setDate(start.getDate() - 13)
- start.setHours(0, 0, 0, 0)
- return { start: start.getTime(), end: end.getTime() }
+ const today = getDateParts(new Date(), timezone)
+ return getZonedDayBoundaryTimestamps(
+ shiftCalendarDays(today, -13),
+ today,
+ timezone
+ )
},
},
{
@@ -49,12 +106,12 @@ export const TIME_RANGE_PRESETS: TimeRangePreset[] = [
label: 'Last 30 days',
shortcut: '30D',
getValue: () => {
- const end = new Date()
- end.setHours(23, 59, 59, 999)
- const start = new Date(end)
- start.setDate(start.getDate() - 29)
- start.setHours(0, 0, 0, 0)
- return { start: start.getTime(), end: end.getTime() }
+ const today = getDateParts(new Date(), timezone)
+ return getZonedDayBoundaryTimestamps(
+ shiftCalendarDays(today, -29),
+ today,
+ timezone
+ )
},
},
{
@@ -62,76 +119,63 @@ export const TIME_RANGE_PRESETS: TimeRangePreset[] = [
label: 'Last 90 days',
shortcut: '90D',
getValue: () => {
- const end = new Date()
- end.setHours(23, 59, 59, 999)
- const start = new Date(end)
- start.setDate(start.getDate() - 89)
- start.setHours(0, 0, 0, 0)
- return { start: start.getTime(), end: end.getTime() }
+ const today = getDateParts(new Date(), timezone)
+ return getZonedDayBoundaryTimestamps(
+ shiftCalendarDays(today, -89),
+ today,
+ timezone
+ )
},
},
{
id: 'this-month',
label: 'This month',
getValue: () => {
- const now = new Date()
- const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0)
- const end = new Date(
- now.getFullYear(),
- now.getMonth() + 1,
- 0,
- 23,
- 59,
- 59,
- 999
+ const today = getDateParts(new Date(), timezone)
+ const start = { year: today.year, month: today.month, day: 1 }
+ return getZonedDayBoundaryTimestamps(
+ start,
+ getMonthEnd(today.year, today.month),
+ timezone
)
- return { start: start.getTime(), end: end.getTime() }
},
},
{
id: 'last-month',
label: 'Last month',
getValue: () => {
- const now = new Date()
- const start = new Date(
- now.getFullYear(),
- now.getMonth() - 1,
- 1,
- 0,
- 0,
- 0,
- 0
+ const today = getDateParts(new Date(), timezone)
+ const start = shiftToMonthStart(today, -1)
+ return getZonedDayBoundaryTimestamps(
+ start,
+ getMonthEnd(start.year, start.month),
+ timezone
)
- const end = new Date(
- now.getFullYear(),
- now.getMonth(),
- 0,
- 23,
- 59,
- 59,
- 999
- )
- return { start: start.getTime(), end: end.getTime() }
},
},
{
id: 'this-year',
label: 'This year',
getValue: () => {
- const now = new Date()
- const start = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0)
- const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999)
- return { start: start.getTime(), end: end.getTime() }
+ const today = getDateParts(new Date(), timezone)
+ return getZonedDayBoundaryTimestamps(
+ { year: today.year, month: 1, day: 1 },
+ { year: today.year, month: 12, day: 31 },
+ timezone
+ )
},
},
{
id: 'last-year',
label: 'Last year',
getValue: () => {
- const now = new Date()
- const start = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0)
- const end = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59, 999)
- return { start: start.getTime(), end: end.getTime() }
+ const today = getDateParts(new Date(), timezone)
+ const year = today.year - 1
+ return getZonedDayBoundaryTimestamps(
+ { year, month: 1, day: 1 },
+ { year, month: 12, day: 31 },
+ timezone
+ )
},
},
]
@@ -173,3 +217,5 @@ export const COMPUTE_CHART_CONFIGS: Record<
yAxisFormatter: formatAxisNumber,
},
}
+
+export { getUsageTimeRangePresets }
diff --git a/src/features/dashboard/usage/display-utils.ts b/src/features/dashboard/usage/display-utils.ts
index fa444f182..159b214ac 100644
--- a/src/features/dashboard/usage/display-utils.ts
+++ b/src/features/dashboard/usage/display-utils.ts
@@ -1,9 +1,8 @@
+import type { Timezone } from '@/features/dashboard/timezone'
import {
formatCurrency,
- formatDateRange,
- formatDay,
- formatHour,
formatNumber,
+ getDateParts,
} from '@/lib/utils/formatting'
import {
determineSamplingMode,
@@ -17,6 +16,64 @@ import type {
Timeframe,
} from './types'
+const isThisYearInTimezone = (timestamp: number, timezone: Timezone): boolean =>
+ getDateParts(timestamp, timezone).year ===
+ getDateParts(new Date(), timezone).year
+
+const formatZonedDay = (timestamp: number, timezone: Timezone): string =>
+ new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ year: isThisYearInTimezone(timestamp, timezone) ? undefined : 'numeric',
+ month: 'short',
+ day: 'numeric',
+ }).format(timestamp)
+
+const formatZonedHour = (timestamp: number, timezone: Timezone): string => {
+ const date = new Date(timestamp)
+ const day = formatZonedDay(timestamp, timezone)
+ const hour = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ hour: 'numeric',
+ hour12: true,
+ })
+ .format(date)
+ .replace(/\s/g, '')
+ .toLowerCase()
+
+ return `${day}, ${hour}`
+}
+
+const formatZonedWeekRange = (
+ startTimestamp: number,
+ endTimestamp: number,
+ timezone: Timezone
+): string => {
+ const startParts = getDateParts(startTimestamp, timezone)
+ const endParts = getDateParts(endTimestamp, timezone)
+ const sameYear = startParts.year === endParts.year
+ const sameMonth = sameYear && startParts.month === endParts.month
+
+ const startFormat = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ month: 'short',
+ day: 'numeric',
+ ...(sameYear ? {} : { year: 'numeric' }),
+ })
+
+ const endFormat = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ month: 'short',
+ day: 'numeric',
+ year: isThisYearInTimezone(endTimestamp, timezone) ? undefined : 'numeric',
+ })
+
+ if (sameMonth) {
+ return `${startFormat.format(startTimestamp)} - ${endParts.day}`
+ }
+
+ return `${startFormat.format(startTimestamp)} - ${endFormat.format(endTimestamp)}`
+}
+
/**
* Format a timestamp to a human-readable date using Intl.DateTimeFormat
* @param timestamp - Unix timestamp in milliseconds
@@ -24,11 +81,13 @@ import type {
*/
export function formatAxisDate(
timestamp: number,
- samplingMode: SamplingMode
+ samplingMode: SamplingMode,
+ timezone: Timezone
): string {
switch (samplingMode) {
case 'hourly':
return new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
month: 'short',
day: 'numeric',
hour: 'numeric',
@@ -36,6 +95,7 @@ export function formatAxisDate(
}).format(new Date(timestamp))
default:
return new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
month: 'short',
day: 'numeric',
}).format(new Date(timestamp))
@@ -51,7 +111,8 @@ export function formatHoveredValues(
vcpuHours: number,
ramGibHours: number,
timestamp: number,
- timeframe: Timeframe
+ timeframe: Timeframe,
+ timezone: Timezone
): {
sandboxes: DisplayValue
cost: DisplayValue
@@ -62,14 +123,25 @@ export function formatHoveredValues(
let label: string
const samplingMode = determineSamplingMode(timeframe)
- // edge bucket keys match the hour containing the timeframe boundary
const normalizedStartTimestamp = normalizeToStartOfSamplingPeriod(
timeframe.start,
- 'hourly'
+ samplingMode,
+ timezone
)
const normalizedEndTimestamp = normalizeToStartOfSamplingPeriod(
timeframe.end,
- 'hourly'
+ samplingMode,
+ timezone
+ )
+ const startBoundaryHour = normalizeToStartOfSamplingPeriod(
+ timeframe.start,
+ 'hourly',
+ timezone
+ )
+ const endBoundaryHour = normalizeToStartOfSamplingPeriod(
+ timeframe.end,
+ 'hourly',
+ timezone
)
const timestampIsAtStartEdge = timestamp === normalizedStartTimestamp
@@ -77,51 +149,53 @@ export function formatHoveredValues(
switch (samplingMode) {
case 'hourly':
- timestampLabel = formatHour(timestamp)
+ timestampLabel = formatZonedHour(timestamp, timezone)
label = 'at'
break
case 'daily':
if (timestampIsAtStartEdge && timestampIsAtEndEdge) {
- // both edges in same bucket - show precise range
- timestampLabel = `${formatHour(normalizedStartTimestamp)} - ${formatHour(normalizedEndTimestamp)}`
+ timestampLabel = `${formatZonedHour(startBoundaryHour, timezone)} - ${formatZonedHour(endBoundaryHour, timezone)}`
label = 'during'
} else if (timestampIsAtStartEdge) {
- // partial day at start - show from start hour to end of day
- const endOfDay = new Date(timestamp)
- endOfDay.setHours(23, 59, 59, 999)
- timestampLabel = `${formatHour(timestamp)} - end of ${formatDay(timestamp)}`
+ timestampLabel = `${formatZonedHour(startBoundaryHour, timezone)} - end of ${formatZonedDay(timestamp, timezone)}`
label = 'during'
} else if (timestampIsAtEndEdge) {
- // partial day at end - show from start of day to end hour
- const startOfDay = new Date(timestamp)
- startOfDay.setHours(0, 0, 0, 0)
- timestampLabel = `${formatDay(timestamp)} - ${formatHour(timestamp)}`
+ timestampLabel = `${formatZonedDay(timestamp, timezone)} - ${formatZonedHour(endBoundaryHour, timezone)}`
label = 'during'
} else {
- timestampLabel = formatDay(timestamp)
+ timestampLabel = formatZonedDay(timestamp, timezone)
label = 'on'
}
break
case 'weekly':
if (timestampIsAtStartEdge && timestampIsAtEndEdge) {
- // both edges in same bucket - show precise range
- timestampLabel = `${formatHour(normalizedStartTimestamp)} - ${formatHour(normalizedEndTimestamp)}`
+ timestampLabel = `${formatZonedHour(startBoundaryHour, timezone)} - ${formatZonedHour(endBoundaryHour, timezone)}`
label = 'during'
} else if (timestampIsAtStartEdge) {
- // partial week at start - show from start hour to end of week
- const weekEnd = normalizeToEndOfSamplingPeriod(timestamp, 'weekly')
- timestampLabel = `${formatHour(timestamp)} - ${formatDay(weekEnd)}`
+ const weekEnd = normalizeToEndOfSamplingPeriod(
+ timestamp,
+ 'weekly',
+ timezone
+ )
+ timestampLabel = `${formatZonedHour(startBoundaryHour, timezone)} - ${formatZonedDay(weekEnd, timezone)}`
label = 'during'
} else if (timestampIsAtEndEdge) {
- // partial week at end - show from start of week to end hour
- const weekStart = normalizeToStartOfSamplingPeriod(timestamp, 'weekly')
- timestampLabel = `${formatDay(weekStart)} - ${formatHour(timestamp)}`
+ const weekStart = normalizeToStartOfSamplingPeriod(
+ timestamp,
+ 'weekly',
+ timezone
+ )
+ timestampLabel = `${formatZonedDay(weekStart, timezone)} - ${formatZonedHour(endBoundaryHour, timezone)}`
label = 'during'
} else {
- const weekEnd = normalizeToEndOfSamplingPeriod(timestamp, 'weekly')
- timestampLabel = formatDateRange(timestamp, weekEnd)
+ const weekEnd = normalizeToEndOfSamplingPeriod(
+ timestamp,
+ 'weekly',
+ timezone
+ )
+ timestampLabel = formatZonedWeekRange(timestamp, weekEnd, timezone)
label = 'during week'
}
break
diff --git a/src/features/dashboard/usage/sampling-utils.ts b/src/features/dashboard/usage/sampling-utils.ts
index 094305fc1..9eb1c9305 100644
--- a/src/features/dashboard/usage/sampling-utils.ts
+++ b/src/features/dashboard/usage/sampling-utils.ts
@@ -1,11 +1,25 @@
-import { startOfISOWeek } from 'date-fns'
import type { UsageResponse } from '@/core/modules/billing/models'
+import type { Timezone } from '@/features/dashboard/timezone'
+import {
+ type CalendarDateTimeParts,
+ dateTimePartsToUtcTimestamp,
+ getDateTimeParts,
+ shiftCalendarDays,
+} from '@/lib/utils/formatting'
import {
HOURLY_SAMPLING_THRESHOLD_DAYS,
WEEKLY_SAMPLING_THRESHOLD_DAYS,
} from './constants'
import type { SampledDataPoint, SamplingMode, Timeframe } from './types'
+const getIsoWeekStartParts = (
+ parts: Pick
+): Pick => {
+ const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
+ const daysSinceMonday = (date.getUTCDay() + 6) % 7
+ return shiftCalendarDays(parts, -daysSinceMonday)
+}
+
export function determineSamplingMode(timeframe: Timeframe): SamplingMode {
const rangeDays = (timeframe.end - timeframe.start) / (24 * 60 * 60 * 1000)
@@ -33,69 +47,104 @@ export function getSamplingModeStepMs(samplingMode: SamplingMode): number {
export function processUsageData(
hourlyData: UsageResponse['hour_usages'],
- timeframe: Timeframe
+ timeframe: Timeframe,
+ timezone: Timezone
): SampledDataPoint[] {
if (!hourlyData || hourlyData.length === 0) {
return []
}
- return aggregateHours(hourlyData, timeframe)
+ return aggregateHours(hourlyData, timeframe, timezone)
}
export function normalizeToStartOfSamplingPeriod(
timestamp: number,
- mode: SamplingMode
+ mode: SamplingMode,
+ timezone: Timezone
): number {
- const date = new Date(timestamp)
+ const parts = getDateTimeParts(timestamp, timezone)
switch (mode) {
case 'hourly':
- return date.setMinutes(0, 0, 0)
+ return dateTimePartsToUtcTimestamp(
+ { ...parts, minutes: 0, seconds: 0 },
+ timezone
+ )
case 'daily':
- date.setMinutes(0, 0, 0)
- return date.setHours(0, 0, 0, 0)
-
- case 'weekly':
- date.setMinutes(0, 0, 0)
- date.setHours(0, 0, 0, 0)
+ return dateTimePartsToUtcTimestamp(
+ { ...parts, hours: 0, minutes: 0, seconds: 0 },
+ timezone
+ )
- return startOfISOWeek(date).getTime()
+ case 'weekly': {
+ const weekStart = getIsoWeekStartParts(parts)
+
+ return dateTimePartsToUtcTimestamp(
+ {
+ ...weekStart,
+ hours: 0,
+ minutes: 0,
+ seconds: 0,
+ },
+ timezone
+ )
+ }
}
}
export function normalizeToEndOfSamplingPeriod(
timestamp: number,
- mode: SamplingMode
+ mode: SamplingMode,
+ timezone: Timezone
): number {
- const date = new Date(timestamp)
+ const parts = getDateTimeParts(timestamp, timezone)
switch (mode) {
case 'hourly':
- date.setMinutes(59, 59, 999)
- return date.getTime()
+ return (
+ dateTimePartsToUtcTimestamp(
+ { ...parts, minutes: 59, seconds: 59 },
+ timezone
+ ) + 999
+ )
case 'daily':
- date.setHours(23, 59, 59, 999)
- return date.getTime()
+ return (
+ dateTimePartsToUtcTimestamp(
+ { ...parts, hours: 23, minutes: 59, seconds: 59 },
+ timezone
+ ) + 999
+ )
case 'weekly': {
- const weekStart = startOfISOWeek(date)
- weekStart.setDate(weekStart.getDate() + 6)
- weekStart.setHours(23, 59, 59, 999)
- return weekStart.getTime()
+ const weekStart = getIsoWeekStartParts(parts)
+ const weekEnd = shiftCalendarDays(weekStart, 6)
+
+ return (
+ dateTimePartsToUtcTimestamp(
+ {
+ ...weekEnd,
+ hours: 23,
+ minutes: 59,
+ seconds: 59,
+ },
+ timezone
+ ) + 999
+ )
}
}
}
/**
* Aggregates hourly usage data into sampling periods (hourly, daily, or weekly).
- * For daily and weekly modes, partial buckets at the start and end of the timeframe
- * are truncated to align with the timeframe boundaries, normalized to hourly timestamps.
+ * Daily and weekly bucket timestamps represent the selected-zone start of that
+ * calendar period while totals include the hourly points returned for the query range.
*/
function aggregateHours(
hourlyData: UsageResponse['hour_usages'],
- timeframe: Timeframe
+ timeframe: Timeframe,
+ timezone: Timezone
): SampledDataPoint[] {
const samplingMode = determineSamplingMode(timeframe)
@@ -110,34 +159,8 @@ function aggregateHours(
}))
}
- // pre-calculate sampling period boundaries for edge bucket detection
- const timeframeStartPeriod = normalizeToStartOfSamplingPeriod(
- timeframe.start,
- samplingMode
- )
- const timeframeEndPeriod = normalizeToStartOfSamplingPeriod(
- timeframe.end,
- samplingMode
- )
-
function createBucketKey(timestamp: number): number {
- const timestampPeriodStart = normalizeToStartOfSamplingPeriod(
- timestamp,
- samplingMode
- )
-
- // // check if timestamp is in the same sampling period as timeframe.start
- // if (timestampPeriodStart === timeframeStartPeriod) {
- // return normalizeToStartOfSamplingPeriod(timeframe.start, 'hourly')
- // }
-
- // // check if timestamp is in the same sampling period as timeframe.end
- // if (timestampPeriodStart === timeframeEndPeriod) {
- // return normalizeToStartOfSamplingPeriod(timeframe.end, 'hourly')
- // }
-
- // middle bucket: use full sampling period
- return timestampPeriodStart
+ return normalizeToStartOfSamplingPeriod(timestamp, samplingMode, timezone)
}
// group data by timestamp buckets
diff --git a/src/features/dashboard/usage/usage-charts-context.tsx b/src/features/dashboard/usage/usage-charts-context.tsx
index 71d8614d6..54dd86713 100644
--- a/src/features/dashboard/usage/usage-charts-context.tsx
+++ b/src/features/dashboard/usage/usage-charts-context.tsx
@@ -10,6 +10,7 @@ import {
useState,
} from 'react'
import type { UsageResponse } from '@/core/modules/billing/models'
+import { useTimezone } from '@/features/dashboard/timezone'
import { fillTimeSeriesWithEmptyPoints } from '@/lib/utils/time-series'
import { INITIAL_TIMEFRAME_FALLBACK_RANGE_MS } from './constants'
import {
@@ -70,6 +71,7 @@ export function UsageChartsProvider({
data,
children,
}: UsageChartsProviderProps) {
+ const { timezone } = useTimezone()
// MUTABLE STATE
const [params, setParams] = useQueryStates(timeframeParams, {
@@ -126,8 +128,9 @@ export function UsageChartsProvider({
}, [data.hour_usages, timeframe])
const sampledData = useMemo(
- () => processUsageData(zeroFilledTimeframeFilteredData, timeframe),
- [zeroFilledTimeframeFilteredData, timeframe]
+ () =>
+ processUsageData(zeroFilledTimeframeFilteredData, timeframe, timezone),
+ [zeroFilledTimeframeFilteredData, timeframe, timezone]
)
const seriesData = useMemo(() => {
@@ -154,23 +157,23 @@ export function UsageChartsProvider({
const displayedData = useMemo(() => {
return {
sandboxes: seriesData.sandboxes.map((d) => ({
- x: formatAxisDate(d.x, samplingMode),
+ x: formatAxisDate(d.x, samplingMode, timezone),
y: d.y,
})),
cost: seriesData.cost.map((d) => ({
- x: formatAxisDate(d.x, samplingMode),
+ x: formatAxisDate(d.x, samplingMode, timezone),
y: d.y,
})),
vcpu: seriesData.vcpu.map((d) => ({
- x: formatAxisDate(d.x, samplingMode),
+ x: formatAxisDate(d.x, samplingMode, timezone),
y: d.y,
})),
ram: seriesData.ram.map((d) => ({
- x: formatAxisDate(d.x, samplingMode),
+ x: formatAxisDate(d.x, samplingMode, timezone),
y: d.y,
})),
}
- }, [seriesData, samplingMode])
+ }, [seriesData, samplingMode, timezone])
const totals = useMemo(
() => calculateTotals(sampledData),
@@ -178,17 +181,24 @@ export function UsageChartsProvider({
)
const displayValues = useMemo(() => {
- if (
- hoveredIndex !== null &&
- seriesData.sandboxes[hoveredIndex] !== undefined
- ) {
+ if (hoveredIndex !== null) {
+ const sandboxPoint = seriesData.sandboxes[hoveredIndex]
+ const costPoint = seriesData.cost[hoveredIndex]
+ const vcpuPoint = seriesData.vcpu[hoveredIndex]
+ const ramPoint = seriesData.ram[hoveredIndex]
+
+ if (!sandboxPoint || !costPoint || !vcpuPoint || !ramPoint) {
+ return formatTotalValues(totals)
+ }
+
return formatHoveredValues(
- seriesData.sandboxes[hoveredIndex].y,
- seriesData.cost[hoveredIndex]!.y,
- seriesData.vcpu[hoveredIndex]!.y,
- seriesData.ram[hoveredIndex]!.y,
- seriesData.sandboxes[hoveredIndex]!.x,
- timeframe
+ sandboxPoint.y,
+ costPoint.y,
+ vcpuPoint.y,
+ ramPoint.y,
+ sandboxPoint.x,
+ timeframe,
+ timezone
)
}
@@ -206,6 +216,7 @@ export function UsageChartsProvider({
seriesData.vcpu,
seriesData.ram,
timeframe,
+ timezone,
])
const setTimeframe = useCallback(
@@ -217,16 +228,17 @@ export function UsageChartsProvider({
const onBrushEnd = useCallback(
(startIndex: number, endIndex: number) => {
+ const startPoint = seriesData.sandboxes[startIndex]
+ const endPoint = seriesData.sandboxes[endIndex]
+ if (!startPoint || !endPoint) return
+
setHoveredIndex(null)
setTimeframe(
- seriesData.sandboxes[startIndex]!.x,
- normalizeToEndOfSamplingPeriod(
- seriesData.sandboxes[endIndex]!.x,
- samplingMode
- )
+ startPoint.x,
+ normalizeToEndOfSamplingPeriod(endPoint.x, samplingMode, timezone)
)
},
- [seriesData.sandboxes, setTimeframe, samplingMode]
+ [seriesData.sandboxes, setTimeframe, samplingMode, timezone]
)
const value = useMemo(
diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx
index a1e11be61..f17590951 100644
--- a/src/features/dashboard/usage/usage-time-range-controls.tsx
+++ b/src/features/dashboard/usage/usage-time-range-controls.tsx
@@ -1,7 +1,9 @@
'use client'
import { useCallback, useMemo, useState } from 'react'
+import { useTimezone } from '@/features/dashboard/timezone'
import { cn } from '@/lib/utils'
+import { formatDateRange } from '@/lib/utils/formatting'
import { findMatchingPreset } from '@/lib/utils/time-range'
import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe'
import CopyButton from '@/ui/copy-button'
@@ -13,10 +15,9 @@ import {
PopoverTrigger,
} from '@/ui/primitives/popover'
import { Separator } from '@/ui/primitives/separator'
-import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker'
-import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic'
+import { TimeRangePicker } from '@/ui/time-range-picker'
import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets'
-import { TIME_RANGE_PRESETS } from './constants'
+import { getUsageTimeRangePresets } from './constants'
import {
determineSamplingMode,
normalizeToEndOfSamplingPeriod,
@@ -41,34 +42,28 @@ export function UsageTimeRangeControls({
onTimeRangeChange,
className,
}: UsageTimeRangeControlsProps) {
+ const { timezone } = useTimezone()
const [isTimePickerOpen, setIsTimePickerOpen] = useState(false)
+ const timeRangePresets = useMemo(
+ () => getUsageTimeRangePresets(timezone),
+ [timezone]
+ )
+
const selectedPresetId = useMemo(
() =>
findMatchingPreset(
- TIME_RANGE_PRESETS,
+ timeRangePresets,
timeframe.start,
timeframe.end,
1000 * 60 * 60 * 24 // 1 day in tolerance
),
- [timeframe.start, timeframe.end]
+ [timeRangePresets, timeframe.start, timeframe.end]
)
const rangeLabel = useMemo(() => {
- const opt: Intl.DateTimeFormatOptions = {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- }
-
- const firstFormatter = new Intl.DateTimeFormat('en-US', opt)
-
- const lastFormatter = new Intl.DateTimeFormat('en-US', {
- ...opt,
- timeZoneName: 'short',
- })
- return `${firstFormatter.format(timeframe.start)} - ${lastFormatter.format(timeframe.end)}`
- }, [timeframe.start, timeframe.end])
+ return formatDateRange(timeframe.start, timeframe.end, { timezone })
+ }, [timeframe.start, timeframe.end, timezone])
const rangeCopyValue = useMemo(
() => formatTimeframeAsISO8601Interval(timeframe.start, timeframe.end),
@@ -85,14 +80,16 @@ export function UsageTimeRangeControls({
onTimeRangeChange(
normalizeToStartOfSamplingPeriod(
timeframe.start - quarterOfRangeDuration,
- samplingMode
+ samplingMode,
+ timezone
),
normalizeToEndOfSamplingPeriod(
timeframe.end - quarterOfRangeDuration,
- samplingMode
+ samplingMode,
+ timezone
)
)
- }, [timeframe, quarterOfRangeDuration, onTimeRangeChange])
+ }, [timeframe, quarterOfRangeDuration, onTimeRangeChange, timezone])
const handleNextRange = useCallback(() => {
const samplingMode = determineSamplingMode(timeframe)
@@ -100,23 +97,20 @@ export function UsageTimeRangeControls({
onTimeRangeChange(
normalizeToStartOfSamplingPeriod(
timeframe.start + quarterOfRangeDuration,
- samplingMode
+ samplingMode,
+ timezone
),
normalizeToEndOfSamplingPeriod(
timeframe.end + quarterOfRangeDuration,
- samplingMode
+ samplingMode,
+ timezone
)
)
- }, [timeframe, quarterOfRangeDuration, onTimeRangeChange])
+ }, [timeframe, quarterOfRangeDuration, onTimeRangeChange, timezone])
const handleTimeRangeApply = useCallback(
- (values: TimeRangeValues) => {
- const timestamps = parseTimeRangeValuesToTimestamps(values)
- if (!timestamps) {
- return
- }
-
- onTimeRangeChange(timestamps.start, timestamps.end)
+ (start: number, end: number) => {
+ onTimeRangeChange(start, end)
setIsTimePickerOpen(false)
},
[onTimeRangeChange]
@@ -166,7 +160,7 @@ export function UsageTimeRangeControls({
startDateTime={new Date(timeframe.start).toISOString()}
endDateTime={new Date(timeframe.end).toISOString()}
bounds={USAGE_TIME_RANGE_BOUNDS}
- onApply={handleTimeRangeApply}
+ onApply={({ start, end }) => handleTimeRangeApply(start, end)}
className="p-3 w-56 max-md:w-full"
/>
part.type === 'timeZoneName'
- )?.value ??
- Intl.DateTimeFormat().resolvedOptions().timeZone ??
- 'Local'
-
- return {
- datePart: (includeYear
- ? LOCAL_LOG_STYLE_DATE_WITH_YEAR_FORMATTER
- : LOCAL_LOG_STYLE_DATE_FORMATTER
- ).format(date),
- timePart: (includeSeconds
- ? LOCAL_LOG_STYLE_TIME_FORMATTER
- : LOCAL_LOG_STYLE_TIME_NO_SECONDS_FORMATTER
- ).format(date),
- subsecondPart: includeCentiseconds
- ? Math.floor((date.getMilliseconds() / 10) % 100)
- .toString()
- .padStart(2, '0')
- : null,
- timezonePart,
- iso: date.toISOString(),
- }
-}
-
-/**
- * Format a timestamp for display in charts and tooltips in user's local timezone
- * @param timestamp - Unix timestamp in milliseconds or Date object
- * @returns Formatted date string in user's local timezone (e.g., "Jan 5, 2:30:45 PM")
- */
-export function formatChartTimestampLocal(
- timestamp: number | string | Date,
- showDate: boolean = false
-): string {
- const date = new Date(timestamp)
-
- if (showDate) {
- return format(date, 'MMM d')
- }
- // format in user's local timezone instead of UTC
- return format(date, 'h:mm:ss a')
-}
-
-/**
- * Format a timestamp for display in charts and tooltips
- * @param timestamp - Unix timestamp in milliseconds or Date object
- * @returns Formatted date string in UTC timezone (e.g., "Jan 5, 2:30:45 PM")
- */
-export function formatChartTimestampUTC(
- timestamp: number | string | Date,
- showDate: boolean = false
-): string {
- const date = new Date(timestamp)
-
- if (showDate) {
- return formatInTimeZone(date, 'UTC', 'MMM d')
- }
-
- return formatInTimeZone(date, 'UTC', 'h:mm:ss a')
-}
-
-/** Formats elapsed time as a compact relative label; e.g. `new Date(Date.now() - 7200000)` -> `"2h ago"` */
-export const formatRelativeAgo = (date: Date): string => {
- const now = Date.now()
- const timestamp = date.getTime()
- const seconds = Math.floor((now - timestamp) / 1000)
- if (seconds < 60) return `${seconds}s ago`
-
- const minutes = Math.floor(seconds / 60)
- if (minutes < 60) return `${minutes} min ago`
-
- const hours = Math.floor(minutes / 60)
- if (hours < 24) return `${hours}h ago`
-
- const days = Math.floor(hours / 24)
- if (days < 7) return `${days}d ago`
-
- const weeks = Math.floor(days / 7)
- if (weeks < 5) return `${weeks}w ago`
-
- if (days < 365) {
- const months = Math.floor(days / 30)
- return `${months}mo ago`
- }
-
- const years = Math.floor(days / 365)
- return `${years}y ago`
-}
-
-/** Formats a UTC timestamp for tooltips; e.g. `new Date('2025-09-29T14:18:49.000Z')` -> `"2025-09-29T14:18:49+00:00"` */
-export const formatUTCTimestamp = (date: Date): string =>
- formatInTimeZone(date, 'UTC', "yyyy-MM-dd'T'HH:mm:ssxxx")
-
-/**
- * Format a date for compact display (used in chart range labels)
- * @param timestamp - Unix timestamp in milliseconds
- * @returns Formatted date string
- */
-export function formatCompactDate(timestamp: number): string {
- const date = new Date(timestamp)
-
- if (isThisYear(date)) {
- return format(date, 'MMM d, h:mm:ss a zzz')
- }
-
- return format(date, 'yyyy MMM d, h:mm:ss a zzz')
-}
-
-const DATE_STRUCTURES = ['MMM d', 'MMM d, yyyy'] as const
-
-type DateStructure = (typeof DATE_STRUCTURES)[number]
-
-/**
- * Returns a formatted date string
- * @param date - Date to format
- * @param dateStructure - Supported date format structure
- * @returns Formatted date string (e.g., "Apr 8, 2026") or null for invalid dates
- */
-export const formatDate = (
- date: Date,
- dateStructure: DateStructure
-): string | null => {
- if (!isValid(date)) return null
- return format(date, dateStructure)
-}
-
-export function formatDay(timestamp: number): string {
- if (isThisYear(timestamp)) {
- return new Intl.DateTimeFormat('en-US', {
- month: 'short',
- day: 'numeric',
- }).format(timestamp)
- }
-
- return new Intl.DateTimeFormat('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- }).format(timestamp)
-}
-
-/**
- * Format a timestamp to show date and hour (for hourly aggregations)
- * @param timestamp - Unix timestamp in milliseconds
- * @returns Formatted string (e.g., "Jan 5, 2pm" or "Jan 5, 2024, 2pm")
- */
-export function formatHour(timestamp: number): string {
- const date = new Date(timestamp)
- const hour = date.getHours()
- const ampm = hour >= 12 ? 'pm' : 'am'
- const hour12 = hour % 12 || 12
-
- if (isThisYear(timestamp)) {
- return (
- new Intl.DateTimeFormat('en-US', {
- month: 'short',
- day: 'numeric',
- }).format(timestamp) + `, ${hour12}${ampm}`
- )
- }
-
- return (
- new Intl.DateTimeFormat('en-US', {
- year: 'numeric',
- month: 'short',
- day: 'numeric',
- }).format(timestamp) + `, ${hour12}${ampm}`
- )
-}
-
-/**
- * Format a date range (e.g., for weekly aggregations)
- * @param startTimestamp - Start of the range in milliseconds
- * @param endTimestamp - End of the range in milliseconds
- * @returns Formatted date range string (e.g., "Jan 1 - Jan 7" or "Dec 26, 2023 - Jan 1, 2024")
- */
-export function formatDateRange(
- startTimestamp: number,
- endTimestamp: number
-): string {
- const startDate = new Date(startTimestamp)
- const endDate = new Date(endTimestamp)
-
- const startYear = startDate.getFullYear()
- const endYear = endDate.getFullYear()
- const startMonth = startDate.getMonth()
- const endMonth = endDate.getMonth()
- const sameYear = startYear === endYear
- const sameMonth = sameYear && startMonth === endMonth
-
- const startFormat = new Intl.DateTimeFormat('en-US', {
- month: 'short',
- day: 'numeric',
- ...(sameYear ? {} : { year: 'numeric' }),
- })
-
- const endFormat = new Intl.DateTimeFormat('en-US', {
- month: 'short',
- day: 'numeric',
- year: isThisYear(endDate) ? undefined : 'numeric',
- })
-
- if (sameMonth) {
- return `${startFormat.format(startDate)} - ${endDate.getDate()}`
- }
-
- return `${startFormat.format(startDate)} - ${endFormat.format(endDate)}`
-}
-
-/**
- * Parse and format a UTC date string into components
- * @param date - Date string or Date object
- * @returns Object with date components
- */
-export function parseUTCDateComponents(date: string | Date) {
- const dateTimeString = new Date(date).toUTCString()
- const [day, dateStr, month, year, time, timezone] = dateTimeString.split(' ')
-
- return {
- day,
- date: dateStr,
- month,
- year,
- time,
- timezone,
- full: dateTimeString,
- }
-}
-
-/**
- * Format time axis labels for charts
- * @param value - Timestamp or date value
- * @param showDate - Whether to show the date (for day boundaries)
- * @param useLocal - Whether to use local timezone instead of UTC
- * @returns Formatted label
- */
-export function formatTimeAxisLabel(
- value: string | number,
- showDate: boolean = false,
- useLocal: boolean = true
-): string {
- const date = new Date(value)
-
- if (useLocal) {
- return formatChartTimestampLocal(date, showDate)
- }
-
- return formatChartTimestampUTC(date, showDate)
-}
-/**
- * Format a duration in milliseconds to human-readable text
- * @param durationMs - Duration in milliseconds
- * @returns Human-readable duration (e.g., "5 seconds", "2 minutes", "1 hour")
- */
-export function formatDuration(durationMs: number): string {
- const seconds = Math.floor(durationMs / 1000)
-
- if (seconds < 60) {
- return `${seconds} second${seconds !== 1 ? 's' : ''}`
- } else if (seconds < 3600) {
- const minutes = Math.floor(seconds / 60)
- return `${minutes} minute${minutes !== 1 ? 's' : ''}`
- } else {
- const hours = Math.floor(seconds / 3600)
- return `${hours} hour${hours !== 1 ? 's' : ''}`
- }
-}
-
-export function formatDurationCompact(
- ms: number,
- showDecimalSeconds = false
-): string {
- const seconds = Math.floor(ms / 1000)
- const minutes = Math.floor(seconds / 60)
- const hours = Math.floor(minutes / 60)
-
- if (hours > 0) {
- const remainingMinutes = minutes % 60
- return `${hours}h ${remainingMinutes}m`
- }
- if (minutes > 0) {
- const remainingSeconds = seconds % 60
- return `${minutes}m ${remainingSeconds}s`
- }
- return showDecimalSeconds
- ? `${seconds}.${Math.floor((ms % 1000) / 100)}s`
- : `${seconds}s`
-}
-
-export function formatTimeAgoCompact(ms: number): string {
- const minutes = Math.floor(ms / 1000 / 60)
- const hours = Math.floor(minutes / 60)
- const days = Math.floor(hours / 24)
- const months = Math.floor(days / 30)
-
- if (minutes < 1) {
- return '< 1m ago'
- }
- if (hours < 1) {
- return `${minutes}m ago`
- }
- if (days < 1) {
- const remainingMinutes = minutes % 60
- return `${hours}h ${remainingMinutes}m ago`
- }
- if (months < 1) {
- const remainingHours = hours % 24
- return `${days}d ${remainingHours}h ago`
- }
-
- const remainingDays = days % 30
- return `${months}mo ${remainingDays}d ago`
-}
-
-/**
- * Format an averaging period text (e.g., "5 seconds average")
- * @param stepMs - Step/period in milliseconds
- * @returns Formatted averaging period text
- */
-export function formatAveragingPeriod(stepMs: number): string {
- return `${formatDuration(stepMs)} average`
-}
-
-// ============================================================================
-// Number Formatting
-// ============================================================================
-
-/**
- * Format a number with locale-specific separators
- * @param value - Number to format
- * @param locale - Locale to use (defaults to 'en-US')
- * @param maxFractionDigits - Maximum decimal places to show (defaults to 0 for whole numbers)
- * @returns Formatted number string
- */
-export function formatNumber(
- value: number,
- locale: string = 'en-US',
- maxFractionDigits: number = 0
-): string {
- return value.toLocaleString(locale, {
- maximumFractionDigits: maxFractionDigits,
- })
-}
-
-/**
- * Format a decimal number with specified precision
- * @param value - Number to format
- * @param decimals - Number of decimal places
- * @param locale - Locale to use (defaults to 'en-US')
- * @returns Formatted number string
- */
-export function formatDecimal(
- value: number,
- decimals: number = 1,
- locale: string = 'en-US'
-): string {
- return value.toLocaleString(locale, {
- minimumFractionDigits: decimals,
- maximumFractionDigits: decimals,
- })
-}
-
-/**
- * Format memory in MB to appropriate unit
- * @param memoryMB - Memory in megabytes
- * @param locale - Locale to use (defaults to 'en-US')
- * @returns Formatted memory string (e.g., "512 MB", "1.5 GB")
- */
-export function formatMemory(
- memoryMB: number,
- locale: string = 'en-US'
-): string {
- if (memoryMB < 1024) {
- return `${formatNumber(memoryMB, locale)} MB`
- }
- return `${formatDecimal(memoryMB / 1024, 1, locale)} GB`
-}
-
-/**
- * Format CPU cores with proper pluralization
- * @param cores - Number of CPU cores
- * @param locale - Locale to use (defaults to 'en-US')
- * @returns Formatted CPU string (e.g., "1 core", "4 cores")
- */
-export function formatCPUCores(
- cores: number,
- locale: string = 'en-US'
-): string {
- return `${formatNumber(cores, locale)} core${cores !== 1 ? 's' : ''}`
-}
-
-/**
- * Returns the singular or plural word for a count
- * @param count - Number used to determine singular vs plural form
- * @param singular - Singular form of the word
- * @param plural - Optional plural form override (defaults to an inferred plural form)
- * @returns Singular or plural word (e.g., "member" or "members")
- */
-export const pluralize = (
- count: number,
- singular: string,
- plural?: string
-): string => {
- if (count === 1) return singular
- if (plural) return plural
- if (/[sxz]$/i.test(singular) || /(ch|sh)$/i.test(singular)) {
- return `${singular}es`
- }
- if (/[^aeiou]y$/i.test(singular)) {
- return `${singular.slice(0, -1)}ies`
- }
- return `${singular}s`
-}
-
-/**
- * Format a number for chart axis labels with smart abbreviation
- * Uses whole numbers when possible, abbreviated for large numbers
- * @param value - Number to format
- * @param locale - Locale to use (defaults to 'en-US')
- * @returns Formatted number suitable for chart axes
- */
-export function formatAxisNumber(
- value: number,
- locale: string = 'en-US'
-): string {
- // For chart axes, we want clean whole numbers when possible
- if (Math.abs(value) >= 1000) {
- // Use compact notation for large numbers on axes for cleaner look
- const formatter = new Intl.NumberFormat(locale, {
- notation: 'compact',
- maximumFractionDigits: 0,
- })
- return formatter.format(value)
- }
-
- if (value < 1 && value > 0) {
- return value.toFixed(2)
- }
-
- return formatNumber(value, locale)
-}
-
-// ============================================================================
-// Date Parsing
-// ============================================================================
-
-/**
- * Try to parse a datetime string into a Date object using Chrono
- * Supports multiple formats including ISO, timestamps, relative times, natural language, and common formats
- * @param input - Date string to parse
- * @returns Date object if parsing succeeds, null otherwise
- */
-export function tryParseDatetime(input: string): Date | null {
- if (!input.trim()) return null
-
- // Try parsing as timestamp first (for performance with numeric inputs)
- const timestamp = Number(input)
- if (!Number.isNaN(timestamp)) {
- // if timestamp is less than 10 digits, multiply by 1000 to get milliseconds
- const date = new Date(
- timestamp < 10000000000 ? timestamp * 1000 : timestamp
- )
- if (isValid(date)) return date
- }
-
- // we use Chrono for all other formats - handles ISO, natural language, relative times, and common formats
- try {
- const parsedDate = chrono.parseDate(input)
- return parsedDate || null
- } catch {
- return null
- }
-}
-
-/**
- * Format a datetime to a standard format for display in inputs
- * @param date - Date to format
- * @returns Formatted datetime string (yyyy-MM-dd HH:mm:ss)
- */
-export function formatDatetimeInput(date: Date): string {
- return format(date, 'yyyy-MM-dd HH:mm:ss')
-}
-
-// ============================================================================
-// Date/Time Component Formatting
-// ============================================================================
-
-/**
- * Format a date for display with slashes and spaces (DD / MM / YYYY)
- * Used in date pickers and forms for better readability
- * @param date - Date to format
- * @returns Formatted date string with spaces (e.g., "15 / 03 / 2024")
- */
-export function formatDateWithSpaces(date: Date | null): string {
- if (!date) return ''
- const day = String(date.getDate()).padStart(2, '0')
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const year = date.getFullYear()
- return `${day} / ${month} / ${year}`
-}
-
-/**
- * Format time components with spaces for display (HH : MM : SS)
- * Used in time pickers for better readability
- * @param hours - Hours as string or number
- * @param minutes - Minutes as string or number
- * @param seconds - Seconds as string or number
- * @returns Formatted time string with spaces (e.g., "14 : 30 : 45")
- */
-export function formatTimeWithSpaces(
- hours: string | number,
- minutes: string | number,
- seconds: string | number
-): string {
- const h = String(hours).padStart(2, '0')
- const m = String(minutes).padStart(2, '0')
- const s = String(seconds).padStart(2, '0')
- return `${h} : ${m} : ${s}`
-}
-
-/**
- * Parse a datetime string into separate date and time components
- * Returns date in YYYY/MM/DD format and time in HH:MM:SS format
- * @param dateTimeStr - Datetime string to parse
- * @returns Object with date and time strings, or empty strings if invalid
- */
-export function parseDateTimeComponents(dateTimeStr: string): {
- date: string
- time: string
-} {
- if (!dateTimeStr) return { date: '', time: '' }
- const parsed = tryParseDatetime(dateTimeStr)
- if (!parsed) return { date: '', time: '' }
-
- const year = parsed.getFullYear()
- const month = String(parsed.getMonth() + 1).padStart(2, '0')
- const day = String(parsed.getDate()).padStart(2, '0')
- const hours = String(parsed.getHours()).padStart(2, '0')
- const minutes = String(parsed.getMinutes()).padStart(2, '0')
- const seconds = String(parsed.getSeconds()).padStart(2, '0')
-
- return {
- date: `${year}/${month}/${day}`,
- time: `${hours}:${minutes}:${seconds}`,
- }
-}
-
-/**
- * Combine separate date and time strings into a Date object
- * @param date - Date string (any format parseable by chrono)
- * @param time - Time string (any format parseable by chrono)
- * @returns Date object or null if invalid
- */
-export function combineDateTimeStrings(
- date: string,
- time: string
-): Date | null {
- if (!date || !time) return null
- return tryParseDatetime(`${date} ${time}`)
-}
-
-/**
- * Format a currency amount with the specified currency and locale
- * Always displays exactly 2 decimal places with standard rounding
- * @param amount - Amount to format
- * @param currency - Currency to use (defaults to 'USD')
- * @param locale - Locale to use (defaults to 'en-US')
- * @returns Formatted currency string (e.g., "$100.00", "100.00 €")
- */
-export function formatCurrency(
- amount: number,
- currency: string = 'USD',
- locale: string = 'en-US'
-): string {
- return Intl.NumberFormat(locale, {
- style: 'currency',
- currency,
- minimumFractionDigits: 2,
- maximumFractionDigits: 2,
- }).format(amount)
-}
diff --git a/src/lib/utils/formatting/index.ts b/src/lib/utils/formatting/index.ts
new file mode 100644
index 000000000..a010250f9
--- /dev/null
+++ b/src/lib/utils/formatting/index.ts
@@ -0,0 +1,80 @@
+export * from './time'
+
+function formatNumber(
+ value: number,
+ locale = 'en-US',
+ maxFractionDigits = 0
+): string {
+ return value.toLocaleString(locale, {
+ maximumFractionDigits: maxFractionDigits,
+ })
+}
+
+function formatDecimal(value: number, decimals = 1, locale = 'en-US'): string {
+ return value.toLocaleString(locale, {
+ minimumFractionDigits: decimals,
+ maximumFractionDigits: decimals,
+ })
+}
+
+function formatMemory(memoryMB: number, locale = 'en-US'): string {
+ if (memoryMB < 1024) {
+ return `${formatNumber(memoryMB, locale)} MB`
+ }
+ return `${formatDecimal(memoryMB / 1024, 1, locale)} GB`
+}
+
+function formatCPUCores(cores: number, locale = 'en-US'): string {
+ return `${formatNumber(cores, locale)} core${cores !== 1 ? 's' : ''}`
+}
+
+const pluralize = (
+ count: number,
+ singular: string,
+ plural?: string
+): string => {
+ if (count === 1) return singular
+ if (plural) return plural
+ if (/[sxz]$/i.test(singular) || /(ch|sh)$/i.test(singular)) {
+ return `${singular}es`
+ }
+ if (/[^aeiou]y$/i.test(singular)) {
+ return `${singular.slice(0, -1)}ies`
+ }
+ return `${singular}s`
+}
+
+function formatAxisNumber(value: number, locale = 'en-US'): string {
+ if (Math.abs(value) >= 1000) {
+ const formatter = new Intl.NumberFormat(locale, {
+ notation: 'compact',
+ maximumFractionDigits: 0,
+ })
+ return formatter.format(value)
+ }
+
+ if (value < 1 && value > 0) {
+ return value.toFixed(2)
+ }
+
+ return formatNumber(value, locale)
+}
+
+function formatCurrency(amount: number, currency = 'USD', locale = 'en-US') {
+ return Intl.NumberFormat(locale, {
+ style: 'currency',
+ currency,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(amount)
+}
+
+export {
+ formatAxisNumber,
+ formatCPUCores,
+ formatCurrency,
+ formatDecimal,
+ formatMemory,
+ formatNumber,
+ pluralize,
+}
diff --git a/src/lib/utils/formatting/time.ts b/src/lib/utils/formatting/time.ts
new file mode 100644
index 000000000..92029c35e
--- /dev/null
+++ b/src/lib/utils/formatting/time.ts
@@ -0,0 +1,621 @@
+import * as chrono from 'chrono-node'
+import { format, isValid } from 'date-fns'
+import { formatInTimeZone, fromZonedTime } from 'date-fns-tz'
+import type { Timezone } from '@/features/dashboard/timezone/schema'
+
+interface CalendarDateParts {
+ year: number
+ month: number
+ day: number
+}
+
+interface CalendarDateTimeParts extends CalendarDateParts {
+ hours: number
+ minutes: number
+ seconds: number
+}
+
+const parseZonedFormatParts = (
+ parts: Intl.DateTimeFormatPart[],
+ fields: readonly Intl.DateTimeFormatPartTypes[]
+): Record =>
+ parts.reduce>((result, part) => {
+ if (fields.includes(part.type)) {
+ result[part.type] = part.value
+ }
+
+ return result
+ }, {})
+
+const parseRequiredInt = (
+ value: string | undefined,
+ fieldName: string
+): number => {
+ const parsed = Number.parseInt(value ?? '', 10)
+ if (Number.isNaN(parsed)) {
+ throw new Error(`Unable to parse zoned ${fieldName}`)
+ }
+
+ return parsed
+}
+
+const getDateParts = (
+ value: string | number | Date,
+ timezone: Timezone
+): CalendarDateParts => {
+ const parts = parseZonedFormatParts(
+ new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ }).formatToParts(new Date(value)),
+ ['year', 'month', 'day']
+ )
+
+ return {
+ year: parseRequiredInt(parts.year, 'year'),
+ month: parseRequiredInt(parts.month, 'month'),
+ day: parseRequiredInt(parts.day, 'day'),
+ }
+}
+
+const getDateTimeParts = (
+ value: string | number | Date,
+ timezone: Timezone
+): CalendarDateTimeParts => {
+ const parts = parseZonedFormatParts(
+ new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hourCycle: 'h23',
+ }).formatToParts(new Date(value)),
+ ['year', 'month', 'day', 'hour', 'minute', 'second']
+ )
+
+ return {
+ year: parseRequiredInt(parts.year, 'year'),
+ month: parseRequiredInt(parts.month, 'month'),
+ day: parseRequiredInt(parts.day, 'day'),
+ hours: parseRequiredInt(parts.hour, 'hour'),
+ minutes: parseRequiredInt(parts.minute, 'minute'),
+ seconds: parseRequiredInt(parts.second, 'second'),
+ }
+}
+
+// Shifts a calendar date without applying local timezone rules; e.g. 2026-06-10 + -89 -> 2026-03-13.
+const shiftCalendarDays = (
+ parts: CalendarDateParts,
+ days: number
+): CalendarDateParts => {
+ const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day))
+ date.setUTCDate(date.getUTCDate() + days)
+
+ return {
+ year: date.getUTCFullYear(),
+ month: date.getUTCMonth() + 1,
+ day: date.getUTCDate(),
+ }
+}
+
+const pad = (value: number): string => String(value).padStart(2, '0')
+
+const formatDateTimeInput = (
+ value: string | number | Date,
+ timezone: Timezone
+): { date: string; time: string } => ({
+ date: formatInTimeZone(value, timezone, 'yyyy/MM/dd'),
+ time: formatInTimeZone(value, timezone, 'HH:mm:ss'),
+})
+
+const instantToCalendarDate = (
+ value: string | number | Date,
+ timezone: Timezone
+): Date => {
+ const { date } = formatDateTimeInput(value, timezone)
+ const [year = 0, month = 0, day = 0] = date.split('/').map(Number)
+
+ return new Date(year, month - 1, day)
+}
+
+// Converts timezone wall-clock parts to UTC; e.g. 2026-06-08 09:00:00 in America/New_York -> 2026-06-08T13:00:00.000Z.
+// DST gaps/overlaps are resolved by date-fns-tz consistently instead of rejected.
+const dateTimePartsToUtcDate = (
+ parts: CalendarDateTimeParts,
+ timezone: Timezone
+): Date => {
+ const wallClockValue = `${parts.year}-${pad(parts.month)}-${pad(
+ parts.day
+ )}T${pad(parts.hours)}:${pad(parts.minutes)}:${pad(parts.seconds)}`
+
+ return fromZonedTime(wallClockValue, timezone)
+}
+
+const dateTimePartsToUtcTimestamp = (
+ parts: CalendarDateTimeParts,
+ timezone: Timezone
+): number => dateTimePartsToUtcDate(parts, timezone).getTime()
+
+const formatTimezoneAbbreviation = (
+ value: string | number | Date,
+ timezone: Timezone
+): string => {
+ const abbreviation = new Intl.DateTimeFormat('en-US', {
+ timeZone: timezone,
+ timeZoneName: 'short',
+ })
+ .formatToParts(new Date(value))
+ .find((part) => part.type === 'timeZoneName')?.value
+
+ return abbreviation ?? timezone
+}
+
+// Static date-fns format strings for known display presets.
+const DATE_FORMAT_PRESETS = {
+ // Jun 8, 2026
+ date: 'MMM d, yyyy',
+ // 9:05:12 AM
+ time: 'h:mm:ss a',
+ // Jun 8, 2026 at 09:05:12 AM
+ 'date-time-padded-hour': "MMM d, yyyy 'at' hh:mm:ss a",
+ // Jun 8
+ 'month-day': 'MMM d',
+ // 2026
+ year: 'yyyy',
+ // 09:05
+ 'time-24h-no-seconds': 'HH:mm',
+ // 09:05:12
+ 'time-24h': 'HH:mm:ss',
+ // 2026-06-08 09:05:12 EDT
+ 'exact-timestamp': 'yyyy-MM-dd HH:mm:ss zzz',
+} as const
+
+type StaticDateFormatPreset = keyof typeof DATE_FORMAT_PRESETS
+
+// Special presets resolved outside DATE_FORMAT_PRESETS:
+// compact-timestamp: Jun 8, 9:05:12 AM EDT (current year) / 2025 Jun 8, 9:05:12 AM EST
+// time-with-centiseconds: 09:05:09.87 AM
+type DateFormat =
+ | StaticDateFormatPreset
+ | 'compact-timestamp'
+ | 'time-with-centiseconds'
+
+// Resolves a preset to a date-fns format string; compact-timestamp varies by year.
+// e.g. current year -> 'MMM d, h:mm:ss a zzz' (Jun 8, 9:05:12 AM EDT)
+// e.g. other year -> 'yyyy MMM d, h:mm:ss a zzz' (2025 Jun 8, 9:05:12 AM EST)
+const resolveDateFormatPreset = (
+ value: Date,
+ timezone: Timezone,
+ preset: StaticDateFormatPreset | 'compact-timestamp'
+): string => {
+ if (preset === 'compact-timestamp') {
+ const timestampYear = formatInTimeZone(value, timezone, 'yyyy')
+ const currentYear = formatInTimeZone(Date.now(), timezone, 'yyyy')
+
+ if (timestampYear === currentYear) return 'MMM d, h:mm:ss a zzz'
+
+ return 'yyyy MMM d, h:mm:ss a zzz'
+ }
+
+ return DATE_FORMAT_PRESETS[preset]
+}
+
+interface FormatDateOptions {
+ timezone: Timezone
+ format?: DateFormat
+}
+
+const formatTimeWithCentiseconds = (
+ value: Date,
+ timezone: Timezone
+): string => {
+ const centiseconds = Math.floor((value.getMilliseconds() / 10) % 100)
+ .toString()
+ .padStart(2, '0')
+
+ return `${formatInTimeZone(value, timezone, 'hh:mm:ss')}.${centiseconds} ${formatInTimeZone(value, timezone, 'a')}`
+}
+
+const formatDate = (
+ value: string | number | Date,
+ { timezone, format = 'date' }: FormatDateOptions
+): string | null => {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return null
+
+ if (format === 'time-with-centiseconds')
+ return formatTimeWithCentiseconds(date, timezone)
+
+ return formatInTimeZone(
+ date,
+ timezone,
+ resolveDateFormatPreset(date, timezone, format)
+ )
+}
+
+interface FormatDateRangeOptions {
+ timezone: Timezone
+ format?: DateFormat
+}
+
+const TIMEZONE_INCLUSIVE_DATE_FORMAT_PRESETS: ReadonlySet = new Set(
+ ['compact-timestamp', 'exact-timestamp']
+)
+
+const formatDateRange = (
+ start: string | number | Date,
+ end: string | number | Date,
+ { timezone, format = 'date' }: FormatDateRangeOptions
+): string => {
+ const startLabel = formatDate(start, { timezone, format }) ?? ''
+ const endLabel = formatDate(end, { timezone, format }) ?? ''
+
+ if (TIMEZONE_INCLUSIVE_DATE_FORMAT_PRESETS.has(format)) {
+ return `${startLabel} - ${endLabel}`
+ }
+
+ return `${startLabel} - ${endLabel} ${formatTimezoneAbbreviation(end, timezone)}`
+}
+
+interface DateTimeParts {
+ datePart: string
+ timePart: string
+ subsecondPart: string | null
+ timezonePart: string
+ iso: string
+}
+
+type DatePartsFormatPreset =
+ | 'date-time'
+ | 'date-time-with-centiseconds'
+ | 'date-year-time-no-seconds'
+
+interface FormatDatePartsOptions {
+ timezone: Timezone
+ format?: DatePartsFormatPreset
+}
+
+const DATE_PARTS_PRESET_OPTIONS: Record<
+ DatePartsFormatPreset,
+ {
+ includeSeconds: boolean
+ includeYear: boolean
+ includeCentiseconds: boolean
+ }
+> = {
+ 'date-time': {
+ includeSeconds: true,
+ includeYear: false,
+ includeCentiseconds: false,
+ },
+ 'date-time-with-centiseconds': {
+ includeSeconds: true,
+ includeYear: false,
+ includeCentiseconds: true,
+ },
+ 'date-year-time-no-seconds': {
+ includeSeconds: false,
+ includeYear: true,
+ includeCentiseconds: false,
+ },
+}
+
+const formatDateParts = (
+ value: string | number | Date,
+ { timezone, format = 'date-time' }: FormatDatePartsOptions
+): DateTimeParts | null => {
+ const date = new Date(value)
+ if (Number.isNaN(date.getTime())) return null
+
+ const { includeSeconds, includeYear, includeCentiseconds } =
+ DATE_PARTS_PRESET_OPTIONS[format]
+
+ const dateFormatterOptions: Intl.DateTimeFormatOptions = {
+ timeZone: timezone,
+ }
+ const dateFormatter = new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: '2-digit',
+ ...dateFormatterOptions,
+ })
+ const dateWithYearFormatter = new Intl.DateTimeFormat(undefined, {
+ month: 'short',
+ day: '2-digit',
+ year: 'numeric',
+ ...dateFormatterOptions,
+ })
+ const timeFormatter = new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false,
+ ...dateFormatterOptions,
+ })
+ const timeNoSecondsFormatter = new Intl.DateTimeFormat(undefined, {
+ hour: '2-digit',
+ minute: '2-digit',
+ hour12: false,
+ ...dateFormatterOptions,
+ })
+ const timezoneFormatter = new Intl.DateTimeFormat(undefined, {
+ timeZoneName: 'short',
+ ...dateFormatterOptions,
+ })
+
+ const timezonePart =
+ timezoneFormatter
+ .formatToParts(date)
+ .find((part) => part.type === 'timeZoneName')?.value ?? timezone
+
+ return {
+ datePart: (includeYear ? dateWithYearFormatter : dateFormatter).format(
+ date
+ ),
+ timePart: (includeSeconds ? timeFormatter : timeNoSecondsFormatter).format(
+ date
+ ),
+ subsecondPart: includeCentiseconds
+ ? Math.floor((date.getMilliseconds() / 10) % 100)
+ .toString()
+ .padStart(2, '0')
+ : null,
+ timezonePart,
+ iso: date.toISOString(),
+ }
+}
+
+// Returns a relative day label for a timestamp; e.g. today in NY -> "Today", prior day -> "Yesterday", else "Jun 9, 2026".
+const getRelativeDay = (
+ value: string | number | Date,
+ timezone: Timezone
+): string => {
+ const valueKey = formatInTimeZone(value, timezone, 'yyyy-MM-dd')
+ const todayKey = formatInTimeZone(Date.now(), timezone, 'yyyy-MM-dd')
+ const yesterdayKey = formatInTimeZone(
+ Date.now() - 24 * 60 * 60 * 1000,
+ timezone,
+ 'yyyy-MM-dd'
+ )
+
+ if (valueKey === todayKey) return 'Today'
+ if (valueKey === yesterdayKey) return 'Yesterday'
+
+ return formatInTimeZone(value, timezone, 'PP')
+}
+
+function formatChartTimestampLocal(
+ timestamp: number | string | Date,
+ showDate = false
+): string {
+ const date = new Date(timestamp)
+
+ if (showDate) {
+ return format(date, 'MMM d')
+ }
+
+ return format(date, 'h:mm:ss a')
+}
+
+function formatChartTimestampUTC(
+ timestamp: number | string | Date,
+ showDate = false
+): string {
+ const date = new Date(timestamp)
+
+ if (showDate) {
+ return formatInTimeZone(date, 'UTC', 'MMM d')
+ }
+
+ return formatInTimeZone(date, 'UTC', 'h:mm:ss a')
+}
+
+const formatRelativeAgo = (date: Date): string => {
+ const now = Date.now()
+ const timestamp = date.getTime()
+ const seconds = Math.floor((now - timestamp) / 1000)
+ if (seconds < 60) return `${seconds}s ago`
+
+ const minutes = Math.floor(seconds / 60)
+ if (minutes < 60) return `${minutes} min ago`
+
+ const hours = Math.floor(minutes / 60)
+ if (hours < 24) return `${hours}h ago`
+
+ const days = Math.floor(hours / 24)
+ if (days < 7) return `${days}d ago`
+
+ const weeks = Math.floor(days / 7)
+ if (weeks < 5) return `${weeks}w ago`
+
+ if (days < 365) {
+ const months = Math.floor(days / 30)
+ return `${months}mo ago`
+ }
+
+ const years = Math.floor(days / 365)
+ return `${years}y ago`
+}
+
+function parseUTCDateComponents(date: string | Date) {
+ const dateTimeString = new Date(date).toUTCString()
+ const [day, dateStr, month, year, time, timezone] = dateTimeString.split(' ')
+
+ return {
+ day,
+ date: dateStr,
+ month,
+ year,
+ time,
+ timezone,
+ full: dateTimeString,
+ }
+}
+
+function formatTimeAxisLabel(
+ value: string | number,
+ showDate = false,
+ useLocal = true
+): string {
+ const date = new Date(value)
+
+ if (useLocal) {
+ return formatChartTimestampLocal(date, showDate)
+ }
+
+ return formatChartTimestampUTC(date, showDate)
+}
+
+function formatDuration(durationMs: number): string {
+ const seconds = Math.floor(durationMs / 1000)
+
+ if (seconds < 60) {
+ return `${seconds} second${seconds !== 1 ? 's' : ''}`
+ }
+ if (seconds < 3600) {
+ const minutes = Math.floor(seconds / 60)
+ return `${minutes} minute${minutes !== 1 ? 's' : ''}`
+ }
+
+ const hours = Math.floor(seconds / 3600)
+ return `${hours} hour${hours !== 1 ? 's' : ''}`
+}
+
+function formatDurationCompact(ms: number, showDecimalSeconds = false): string {
+ const seconds = Math.floor(ms / 1000)
+ const minutes = Math.floor(seconds / 60)
+ const hours = Math.floor(minutes / 60)
+
+ if (hours > 0) {
+ const remainingMinutes = minutes % 60
+ return `${hours}h ${remainingMinutes}m`
+ }
+ if (minutes > 0) {
+ const remainingSeconds = seconds % 60
+ return `${minutes}m ${remainingSeconds}s`
+ }
+ return showDecimalSeconds
+ ? `${seconds}.${Math.floor((ms % 1000) / 100)}s`
+ : `${seconds}s`
+}
+
+function formatTimeAgoCompact(ms: number): string {
+ const minutes = Math.floor(ms / 1000 / 60)
+ const hours = Math.floor(minutes / 60)
+ const days = Math.floor(hours / 24)
+ const months = Math.floor(days / 30)
+
+ if (minutes < 1) {
+ return '< 1m ago'
+ }
+ if (hours < 1) {
+ return `${minutes}m ago`
+ }
+ if (days < 1) {
+ const remainingMinutes = minutes % 60
+ return `${hours}h ${remainingMinutes}m ago`
+ }
+ if (months < 1) {
+ const remainingHours = hours % 24
+ return `${days}d ${remainingHours}h ago`
+ }
+
+ const remainingDays = days % 30
+ return `${months}mo ${remainingDays}d ago`
+}
+
+function formatAveragingPeriod(stepMs: number): string {
+ return `${formatDuration(stepMs)} average`
+}
+
+function tryParseDatetime(input: string): Date | null {
+ if (!input.trim()) return null
+
+ const timestamp = Number(input)
+ if (!Number.isNaN(timestamp)) {
+ const date = new Date(
+ timestamp < 10000000000 ? timestamp * 1000 : timestamp
+ )
+ if (isValid(date)) return date
+ }
+
+ try {
+ const parsedDate = chrono.parseDate(input)
+ return parsedDate || null
+ } catch {
+ return null
+ }
+}
+
+function formatDateWithSpaces(date: Date | null): string {
+ if (!date) return ''
+ const day = String(date.getDate()).padStart(2, '0')
+ const month = String(date.getMonth() + 1).padStart(2, '0')
+ const year = date.getFullYear()
+ return `${day} / ${month} / ${year}`
+}
+
+function formatTimeWithSpaces(
+ hours: string | number,
+ minutes: string | number,
+ seconds: string | number
+): string {
+ const h = String(hours).padStart(2, '0')
+ const m = String(minutes).padStart(2, '0')
+ const s = String(seconds).padStart(2, '0')
+ return `${h} : ${m} : ${s}`
+}
+
+function parseDateTimeComponents(dateTimeStr: string): {
+ date: string
+ time: string
+} {
+ if (!dateTimeStr) return { date: '', time: '' }
+ const parsed = tryParseDatetime(dateTimeStr)
+ if (!parsed) return { date: '', time: '' }
+
+ const year = parsed.getFullYear()
+ const month = String(parsed.getMonth() + 1).padStart(2, '0')
+ const day = String(parsed.getDate()).padStart(2, '0')
+ const hours = String(parsed.getHours()).padStart(2, '0')
+ const minutes = String(parsed.getMinutes()).padStart(2, '0')
+ const seconds = String(parsed.getSeconds()).padStart(2, '0')
+
+ return {
+ date: `${year}/${month}/${day}`,
+ time: `${hours}:${minutes}:${seconds}`,
+ }
+}
+
+export {
+ dateTimePartsToUtcDate,
+ dateTimePartsToUtcTimestamp,
+ formatAveragingPeriod,
+ formatChartTimestampLocal,
+ formatChartTimestampUTC,
+ formatDate,
+ formatDateParts,
+ formatDateRange,
+ formatDateTimeInput,
+ formatDateWithSpaces,
+ formatDuration,
+ formatDurationCompact,
+ formatRelativeAgo,
+ formatTimeAgoCompact,
+ formatTimeAxisLabel,
+ formatTimeWithSpaces,
+ formatTimezoneAbbreviation,
+ getDateParts,
+ getDateTimeParts,
+ getRelativeDay,
+ instantToCalendarDate,
+ parseDateTimeComponents,
+ parseUTCDateComponents,
+ shiftCalendarDays,
+ tryParseDatetime,
+}
+export type { CalendarDateTimeParts, DateFormat }
diff --git a/src/ui/time-input.tsx b/src/ui/time-input.tsx
index 570b183c4..dfc692675 100644
--- a/src/ui/time-input.tsx
+++ b/src/ui/time-input.tsx
@@ -1,10 +1,12 @@
'use client'
import { memo, useCallback, useEffect, useState } from 'react'
+import { useTimezone } from '@/features/dashboard/timezone'
import { cn } from '@/lib/utils'
import {
formatDateWithSpaces,
formatTimeWithSpaces,
+ formatTimezoneAbbreviation,
tryParseDatetime,
} from '@/lib/utils/formatting'
import { NumberInput } from './number-input'
@@ -17,32 +19,6 @@ import { Popover, PopoverContent, PopoverTrigger } from './primitives/popover'
// reference date for parsing time-only values - actual date doesn't matter
const REFERENCE_DATE = '2024-01-01'
-// get timezone identifier in developer-friendly format (e.g., GMT+2, PST, CET)
-function getTimezoneIdentifier(): string {
- const date = new Date()
-
- // try to get timezone abbreviation first (PST, CET, etc.)
- const shortTimeString = date.toLocaleTimeString('en-US', {
- timeZoneName: 'short',
- })
- const match = shortTimeString.match(/\b([A-Z]{2,5})\b$/)
-
- if (match && match[1] && !match[1].startsWith('GMT')) {
- return match[1]
- }
-
- // fallback to GMT offset format
- const offset = -date.getTimezoneOffset()
- const hours = Math.floor(Math.abs(offset) / 60)
- const minutes = Math.abs(offset) % 60
- const sign = offset >= 0 ? '+' : '-'
-
- if (minutes === 0) {
- return `GMT${sign}${hours}`
- }
- return `GMT${sign}${hours}:${String(minutes).padStart(2, '0')}`
-}
-
export interface TimeInputProps {
dateValue: string
timeValue: string
@@ -70,6 +46,7 @@ export const TimeInput = memo(function TimeInput({
maxDate,
hideTime = false,
}: TimeInputProps) {
+ const { timezone } = useTimezone()
const [dateOpen, setDateOpen] = useState(false)
const [timeOpen, setTimeOpen] = useState(false)
@@ -83,6 +60,7 @@ export const TimeInput = memo(function TimeInput({
const [hours, setHours] = useState(timeDate ? timeDate.getHours() : 0)
const [minutes, setMinutes] = useState(timeDate ? timeDate.getMinutes() : 0)
const [seconds, setSeconds] = useState(timeDate ? timeDate.getSeconds() : 0)
+ const timezoneIdentifier = formatTimezoneAbbreviation(Date.now(), timezone)
useEffect(() => {
setDisplayDate(dateValue || '')
@@ -191,7 +169,7 @@ export const TimeInput = memo(function TimeInput({
/>
- {getTimezoneIdentifier()}
+ {timezoneIdentifier}
diff --git a/src/ui/time-range-picker.logic.ts b/src/ui/time-range-picker.logic.ts
index 36f571791..95c5fb128 100644
--- a/src/ui/time-range-picker.logic.ts
+++ b/src/ui/time-range-picker.logic.ts
@@ -1,4 +1,7 @@
import { z } from 'zod'
+import type { Timezone } from '@/features/dashboard/timezone'
+import { getBrowserTimezone } from '@/features/dashboard/timezone/utils'
+import { dateTimePartsToUtcDate } from '@/lib/utils/formatting'
export interface TimeRangeValues {
startDate: string
@@ -25,9 +28,10 @@ export interface TimeRangeValidationResult {
issues: TimeRangeIssue[]
}
-interface TimeRangeValidationOptions {
+export interface TimeRangeValidationOptions {
hideTime: boolean
bounds?: TimeRangePickerBounds
+ timezone?: Timezone
}
function normalizeDateInput(value: string): string {
@@ -82,12 +86,12 @@ function parseDateInput(value: string): Date | null {
return null
}
- const parsed = new Date(year, month - 1, day)
+ const parsed = new Date(Date.UTC(year, month - 1, day))
if (
- parsed.getFullYear() !== year ||
- parsed.getMonth() !== month - 1 ||
- parsed.getDate() !== day
+ parsed.getUTCFullYear() !== year ||
+ parsed.getUTCMonth() !== month - 1 ||
+ parsed.getUTCDate() !== day
) {
return null
}
@@ -147,9 +151,9 @@ export function toSecondPrecision(date: Date): Date {
}
function formatDateValue(date: Date): string {
- const year = date.getFullYear()
- const month = String(date.getMonth() + 1).padStart(2, '0')
- const day = String(date.getDate()).padStart(2, '0')
+ const year = date.getUTCFullYear()
+ const month = String(date.getUTCMonth() + 1).padStart(2, '0')
+ const day = String(date.getUTCDate()).padStart(2, '0')
return `${year}/${month}/${day}`
}
@@ -167,7 +171,8 @@ function formatTimeValue(
export function parsePickerDateTime(
dateInput: string,
timeInput: string | null | undefined,
- fallbackTime: string
+ fallbackTime: string,
+ timezone: Timezone = getBrowserTimezone()
): Date | null {
const parsedDate = parseDateInput(dateInput)
if (!parsedDate) {
@@ -181,30 +186,39 @@ export function parsePickerDateTime(
return null
}
- return new Date(
- parsedDate.getFullYear(),
- parsedDate.getMonth(),
- parsedDate.getDate(),
- parsedTime.hours,
- parsedTime.minutes,
- parsedTime.seconds,
- 0
+ return dateTimePartsToUtcDate(
+ {
+ year: parsedDate.getUTCFullYear(),
+ month: parsedDate.getUTCMonth() + 1,
+ day: parsedDate.getUTCDate(),
+ hours: parsedTime.hours,
+ minutes: parsedTime.minutes,
+ seconds: parsedTime.seconds,
+ },
+ timezone
)
}
-function formatBoundaryDateTime(date: Date, hideTime: boolean): string {
- if (hideTime) {
- return date.toLocaleDateString()
- }
-
- return date.toLocaleString(undefined, {
+function formatBoundaryDateTime(
+ date: Date,
+ hideTime: boolean,
+ timezone: Timezone
+): string {
+ const options: Intl.DateTimeFormatOptions = {
+ timeZone: timezone,
year: 'numeric',
month: 'numeric',
day: 'numeric',
- hour: '2-digit',
- minute: '2-digit',
- second: '2-digit',
- })
+ timeZoneName: 'short',
+ }
+
+ if (!hideTime) {
+ options.hour = '2-digit'
+ options.minute = '2-digit'
+ options.second = '2-digit'
+ }
+
+ return new Intl.DateTimeFormat('en-US', options).format(date)
}
function normalizeTimeValue(time: string | null): string | null {
@@ -243,17 +257,20 @@ export function normalizeTimeRangeValues(
}
export function parseTimeRangeValuesToTimestamps(
- values: TimeRangeValues
+ values: TimeRangeValues,
+ timezone: Timezone = getBrowserTimezone()
): { start: number; end: number } | null {
const startDateTime = parsePickerDateTime(
values.startDate,
values.startTime,
- '00:00:00'
+ '00:00:00',
+ timezone
)
const endDateTime = parsePickerDateTime(
values.endDate,
values.endTime,
- '23:59:59'
+ '23:59:59',
+ timezone
)
if (!startDateTime || !endDateTime) {
@@ -268,19 +285,25 @@ export function parseTimeRangeValuesToTimestamps(
export function validateTimeRangeValues(
values: TimeRangeValues,
- { bounds, hideTime }: TimeRangeValidationOptions
+ {
+ bounds,
+ hideTime,
+ timezone = getBrowserTimezone(),
+ }: TimeRangeValidationOptions
): TimeRangeValidationResult {
const issues: TimeRangeIssue[] = []
const startDateTime = parsePickerDateTime(
values.startDate,
hideTime ? null : values.startTime,
- '00:00:00'
+ '00:00:00',
+ timezone
)
const endDateTime = parsePickerDateTime(
values.endDate,
hideTime ? null : values.endTime,
- '23:59:59'
+ '23:59:59',
+ timezone
)
if (!startDateTime) {
@@ -311,14 +334,14 @@ export function validateTimeRangeValues(
if (minBoundary && startDateTime.getTime() < minBoundary.getTime()) {
issues.push({
field: 'startDate',
- message: `Start date cannot be before ${formatBoundaryDateTime(minBoundary, hideTime)}`,
+ message: `Start date cannot be before ${formatBoundaryDateTime(minBoundary, hideTime, timezone)}`,
})
}
if (maxBoundary && endDateTime.getTime() > maxBoundary.getTime()) {
issues.push({
field: 'endDate',
- message: `End date cannot be after ${formatBoundaryDateTime(maxBoundary, hideTime)}`,
+ message: `End date cannot be after ${formatBoundaryDateTime(maxBoundary, hideTime, timezone)}`,
})
}
diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx
index fba258d7f..d329bc6a7 100644
--- a/src/ui/time-range-picker.tsx
+++ b/src/ui/time-range-picker.tsx
@@ -1,12 +1,14 @@
'use client'
import { zodResolver } from '@hookform/resolvers/zod'
-import { endOfDay, startOfDay } from 'date-fns'
import { useCallback, useEffect, useMemo } from 'react'
import { useForm } from 'react-hook-form'
-
+import { useTimezone } from '@/features/dashboard/timezone'
import { cn } from '@/lib/utils'
-import { parseDateTimeComponents } from '@/lib/utils/formatting'
+import {
+ formatDateTimeInput,
+ instantToCalendarDate,
+} from '@/lib/utils/formatting'
import { Button } from './primitives/button'
import {
@@ -21,17 +23,24 @@ import { TimeInput } from './time-input'
import {
createTimeRangeSchema,
normalizeTimeRangeValues,
+ parseTimeRangeValuesToTimestamps,
type TimeRangePickerBounds,
type TimeRangeValues,
} from './time-range-picker.logic'
export type { TimeRangeValues } from './time-range-picker.logic'
+export interface TimeRangeApplyResult {
+ values: TimeRangeValues
+ start: number
+ end: number
+}
+
interface TimeRangePickerProps {
startDateTime: string
endDateTime: string
bounds?: TimeRangePickerBounds
- onApply?: (values: TimeRangeValues) => void
+ onApply?: (result: TimeRangeApplyResult) => void
onChange?: (values: TimeRangeValues) => void
className?: string
hideTime?: boolean
@@ -48,28 +57,33 @@ export function TimeRangePicker({
}: TimeRangePickerProps) {
'use no memo'
+ const { timezone } = useTimezone()
const minBoundMs = bounds?.min?.getTime()
const maxBoundMs = bounds?.max?.getTime()
const startParts = useMemo(
- () => parseDateTimeComponents(startDateTime),
- [startDateTime]
+ () => formatDateTimeInput(startDateTime, timezone),
+ [startDateTime, timezone]
)
const endParts = useMemo(
- () => parseDateTimeComponents(endDateTime),
- [endDateTime]
+ () => formatDateTimeInput(endDateTime, timezone),
+ [endDateTime, timezone]
)
const calendarMinDate = useMemo(
() =>
- minBoundMs !== undefined ? startOfDay(new Date(minBoundMs)) : undefined,
- [minBoundMs]
+ minBoundMs !== undefined
+ ? instantToCalendarDate(minBoundMs, timezone)
+ : undefined,
+ [minBoundMs, timezone]
)
const calendarMaxDate = useMemo(
() =>
- maxBoundMs !== undefined ? endOfDay(new Date(maxBoundMs)) : undefined,
- [maxBoundMs]
+ maxBoundMs !== undefined
+ ? instantToCalendarDate(maxBoundMs, timezone)
+ : undefined,
+ [maxBoundMs, timezone]
)
const schema = useMemo(() => {
@@ -79,8 +93,9 @@ export function TimeRangePicker({
min: minBoundMs !== undefined ? new Date(minBoundMs) : undefined,
max: maxBoundMs !== undefined ? new Date(maxBoundMs) : undefined,
},
+ timezone,
})
- }, [hideTime, maxBoundMs, minBoundMs])
+ }, [hideTime, maxBoundMs, minBoundMs, timezone])
const defaultValues = useMemo(
() => ({
@@ -127,10 +142,16 @@ export function TimeRangePicker({
const handleSubmit = useCallback(
(values: TimeRangeValues) => {
const normalizedValues = normalizeTimeRangeValues(values)
- onApply?.(normalizedValues)
+ const timestamps = parseTimeRangeValuesToTimestamps(
+ normalizedValues,
+ timezone
+ )
+ if (timestamps) {
+ onApply?.({ values: normalizedValues, ...timestamps })
+ }
form.reset(normalizedValues)
},
- [form, onApply]
+ [form, onApply, timezone]
)
const shouldValidateOnChange = form.formState.submitCount > 0
diff --git a/tests/unit/chart-utils.test.ts b/tests/unit/chart-utils.test.ts
index 5b0ad4829..953e26137 100644
--- a/tests/unit/chart-utils.test.ts
+++ b/tests/unit/chart-utils.test.ts
@@ -1,9 +1,23 @@
import { describe, expect, it } from 'vitest'
import type { ClientTeamMetric } from '@/core/modules/sandboxes/models.client'
-import { transformMetrics } from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils'
+import {
+ createTimeAxisLabelFormatter,
+ transformMetrics,
+} from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils'
import { calculateAxisMax } from '@/lib/utils/chart'
+import { requireTimezone } from './helpers/timezone'
describe('team-metrics-chart-utils', () => {
+ const newYork = requireTimezone('America/New_York')
+
+ describe('createTimeAxisLabelFormatter', () => {
+ it('uses hour labels for short ranges', () => {
+ const formatter = createTimeAxisLabelFormatter(newYork, 60 * 60 * 1000)
+
+ expect(formatter(Date.UTC(2026, 5, 8, 13, 0, 0))).toBe('09:00')
+ })
+ })
+
describe('calculateYAxisMax', () => {
it('should round to nice numbers based on data', () => {
const data = [
diff --git a/tests/unit/formatting.test.ts b/tests/unit/formatting.test.ts
index 60f092fdd..86ef63c37 100644
--- a/tests/unit/formatting.test.ts
+++ b/tests/unit/formatting.test.ts
@@ -3,9 +3,7 @@ import {
formatAveragingPeriod,
formatChartTimestampLocal,
formatChartTimestampUTC,
- formatCompactDate,
formatCPUCores,
- formatDate,
formatDecimal,
formatDuration,
formatMemory,
@@ -62,37 +60,6 @@ describe('Date & Time Formatting', () => {
})
})
- describe('formatCompactDate', () => {
- it('formats current year date without year', () => {
- const timestamp = new Date('2024-01-05T14:30:00Z').getTime()
- const result = formatCompactDate(timestamp)
- expect(result).toContain('Jan 5')
- expect(result).not.toContain('2024')
- })
-
- it('formats different year date with year', () => {
- const timestamp = new Date('2023-01-05T14:30:00Z').getTime()
- const result = formatCompactDate(timestamp)
- expect(result).toContain('2023')
- })
- })
-
- describe('formatDate', () => {
- it('formats a date with the requested structure', () => {
- const date = new Date('2024-01-05T14:30:00Z')
- expect(formatDate(date, 'MMM d')).toBe('Jan 5')
- })
-
- it('supports a format that includes the year', () => {
- const date = new Date('2024-01-05T14:30:00Z')
- expect(formatDate(date, 'MMM d, yyyy')).toBe('Jan 5, 2024')
- })
-
- it('returns null for invalid dates', () => {
- expect(formatDate(new Date('not-a-date'), 'MMM d')).toBeNull()
- })
- })
-
describe('parseUTCDateComponents', () => {
it('parses UTC date into components', () => {
const date = new Date('2024-01-05T14:30:45Z')
diff --git a/tests/unit/helpers/timezone.ts b/tests/unit/helpers/timezone.ts
new file mode 100644
index 000000000..ca9cd9f61
--- /dev/null
+++ b/tests/unit/helpers/timezone.ts
@@ -0,0 +1,11 @@
+import type { Timezone } from '@/features/dashboard/timezone'
+import { parseTimezone } from '@/features/dashboard/timezone/utils'
+
+const requireTimezone = (value: string): Timezone => {
+ const timezone = parseTimezone(value)
+ if (!timezone) throw new Error(`Expected ${value} to be a valid timezone`)
+
+ return timezone
+}
+
+export { requireTimezone }
diff --git a/tests/unit/team-monitoring-time-picker.test.ts b/tests/unit/team-monitoring-time-picker.test.ts
new file mode 100644
index 000000000..c7de92135
--- /dev/null
+++ b/tests/unit/team-monitoring-time-picker.test.ts
@@ -0,0 +1,49 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { formatTimeframeValues } from '@/features/dashboard/sandboxes/monitoring/time-picker/utils'
+import { createCustomTimeFormSchema } from '@/features/dashboard/sandboxes/monitoring/time-picker/validation'
+import { requireTimezone } from './helpers/timezone'
+
+describe('team monitoring time picker', () => {
+ const newYork = requireTimezone('America/New_York')
+ const losAngeles = requireTimezone('America/Los_Angeles')
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-06-10T16:00:00.000Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('formats static timeframe values in the selected timezone', () => {
+ const value = {
+ mode: 'static' as const,
+ start: Date.UTC(2026, 5, 10, 13, 0, 0),
+ end: Date.UTC(2026, 5, 10, 14, 0, 0),
+ }
+
+ expect(formatTimeframeValues(value, newYork)).toEqual({
+ startDateTime: '2026/06/10 09:00:00',
+ endDateTime: '2026/06/10 10:00:00',
+ })
+ expect(formatTimeframeValues(value, losAngeles)).toEqual({
+ startDateTime: '2026/06/10 06:00:00',
+ endDateTime: '2026/06/10 07:00:00',
+ })
+ })
+
+ it('validates custom wall-clock input in the selected timezone', () => {
+ const schema = createCustomTimeFormSchema(newYork)
+
+ const result = schema.safeParse({
+ startDate: '2026/06/10',
+ startTime: '09:00:00',
+ endDate: '2026/06/10',
+ endTime: '10:00:00',
+ endEnabled: true,
+ })
+
+ expect(result.success).toBe(true)
+ })
+})
diff --git a/tests/unit/time-range-picker-logic.test.ts b/tests/unit/time-range-picker-logic.test.ts
index 73f17cb6b..777142cc9 100644
--- a/tests/unit/time-range-picker-logic.test.ts
+++ b/tests/unit/time-range-picker-logic.test.ts
@@ -1,4 +1,6 @@
-import { describe, expect, it } from 'vitest'
+import { startOfDay } from 'date-fns'
+import { describe, expect, it, vi } from 'vitest'
+import { instantToCalendarDate } from '@/lib/utils/formatting'
import {
createTimeRangeSchema,
normalizeTimeRangeValues,
@@ -7,6 +9,10 @@ import {
type TimeRangeValues,
validateTimeRangeValues,
} from '@/ui/time-range-picker.logic'
+import { requireTimezone } from './helpers/timezone'
+
+const utc = requireTimezone('UTC')
+const newYork = requireTimezone('America/New_York')
const baseValues: TimeRangeValues = {
startDate: '2026/02/18',
@@ -18,7 +24,7 @@ const baseValues: TimeRangeValues = {
describe('time-range-picker logic', () => {
describe('parsePickerDateTime', () => {
it('returns null when date is missing, even if time exists', () => {
- const parsed = parsePickerDateTime('', '18:00:00', '23:59:59')
+ const parsed = parsePickerDateTime('', '18:00:00', '23:59:59', utc)
expect(parsed).toBeNull()
})
@@ -26,18 +32,42 @@ describe('time-range-picker logic', () => {
const canonical = parsePickerDateTime(
'2026/02/24',
'18:17:41',
- '23:59:59'
+ '23:59:59',
+ utc
)
const display = parsePickerDateTime(
'24 / 02 / 2026',
'18 : 17 : 41',
- '23:59:59'
+ '23:59:59',
+ utc
)
expect(canonical).not.toBeNull()
expect(display).not.toBeNull()
expect(canonical?.getTime()).toBe(display?.getTime())
})
+
+ it('validates calendar dates without relying on browser-local midnight', () => {
+ const parsed = parsePickerDateTime(
+ '2026/12/31',
+ '23:00:00',
+ '00:00:00',
+ utc
+ )
+
+ expect(parsed?.toISOString()).toBe('2026-12-31T23:00:00.000Z')
+ })
+
+ it('interprets wall-clock values in the selected timezone', () => {
+ const parsed = parsePickerDateTime(
+ '2026/06/08',
+ '09:00:00',
+ '00:00:00',
+ newYork
+ )
+
+ expect(parsed?.toISOString()).toBe('2026-06-08T13:00:00.000Z')
+ })
})
describe('normalizeTimeRangeValues', () => {
@@ -68,8 +98,9 @@ describe('time-range-picker logic', () => {
},
{
hideTime: false,
+ timezone: utc,
bounds: {
- min: new Date(2023, 0, 1, 0, 0, 0),
+ min: new Date(Date.UTC(2023, 0, 1, 0, 0, 0)),
},
}
)
@@ -80,8 +111,9 @@ describe('time-range-picker logic', () => {
it('validates against explicit max boundary', () => {
const validation = validateTimeRangeValues(baseValues, {
hideTime: false,
+ timezone: utc,
bounds: {
- max: new Date(2026, 1, 24, 18, 17, 41),
+ max: new Date(Date.UTC(2026, 1, 24, 18, 17, 41)),
},
})
@@ -107,6 +139,7 @@ describe('time-range-picker logic', () => {
},
{
hideTime: false,
+ timezone: utc,
}
)
@@ -123,16 +156,36 @@ describe('time-range-picker logic', () => {
describe('parseTimeRangeValuesToTimestamps', () => {
it('converts values to start and end timestamps with fallback times', () => {
- const timestamps = parseTimeRangeValuesToTimestamps({
- startDate: '2026/02/18',
- startTime: null,
- endDate: '2026/02/24',
- endTime: null,
- })
+ const timestamps = parseTimeRangeValuesToTimestamps(
+ {
+ startDate: '2026/02/18',
+ startTime: null,
+ endDate: '2026/02/24',
+ endTime: null,
+ },
+ utc
+ )
expect(timestamps).not.toBeNull()
- expect(timestamps?.start).toBe(new Date(2026, 1, 18, 0, 0, 0).getTime())
- expect(timestamps?.end).toBe(new Date(2026, 1, 24, 23, 59, 59).getTime())
+ expect(timestamps?.start).toBe(Date.UTC(2026, 1, 18, 0, 0, 0))
+ expect(timestamps?.end).toBe(Date.UTC(2026, 1, 24, 23, 59, 59))
+ })
+
+ it('converts values to UTC timestamps from the selected timezone', () => {
+ const timestamps = parseTimeRangeValuesToTimestamps(
+ {
+ startDate: '2026/06/08',
+ startTime: '09:00:00',
+ endDate: '2026/06/08',
+ endTime: '10:00:00',
+ },
+ newYork
+ )
+
+ expect(timestamps).toEqual({
+ start: Date.UTC(2026, 5, 8, 13, 0, 0),
+ end: Date.UTC(2026, 5, 8, 14, 0, 0),
+ })
})
})
@@ -140,8 +193,9 @@ describe('time-range-picker logic', () => {
it('applies the same boundary validation as logic helpers', () => {
const schema = createTimeRangeSchema({
hideTime: false,
+ timezone: utc,
bounds: {
- max: new Date(2026, 1, 24, 18, 17, 41),
+ max: new Date(Date.UTC(2026, 1, 24, 18, 17, 41)),
},
})
@@ -155,4 +209,49 @@ describe('time-range-picker logic', () => {
}
})
})
+
+ describe('calendar boundary dates', () => {
+ it('aligns disabled calendar days with dashboard timezone validation', () => {
+ const minBound = new Date('2023-01-01T00:00:00.000Z')
+ const calendarMin = instantToCalendarDate(minBound, utc)
+
+ vi.stubEnv('TZ', 'America/Los_Angeles')
+ const browserLocalMin = startOfDay(minBound)
+ vi.unstubAllEnvs()
+
+ expect(browserLocalMin.getDate()).toBe(31)
+ expect(calendarMin.getFullYear()).toBe(2023)
+ expect(calendarMin.getMonth()).toBe(0)
+ expect(calendarMin.getDate()).toBe(1)
+
+ const disabledDay = new Date(2022, 11, 31)
+ const enabledDay = new Date(2023, 0, 1)
+
+ expect(disabledDay < calendarMin).toBe(true)
+ expect(enabledDay < calendarMin).toBe(false)
+
+ const disabledValidation = validateTimeRangeValues(
+ {
+ startDate: '2022/12/31',
+ startTime: '00:00:00',
+ endDate: '2023/01/02',
+ endTime: '23:59:59',
+ },
+ {
+ hideTime: false,
+ timezone: utc,
+ bounds: { min: minBound },
+ }
+ )
+
+ expect(disabledValidation.issues).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({
+ field: 'startDate',
+ message: expect.stringContaining('Start date cannot be before'),
+ }),
+ ])
+ )
+ })
+ })
})
diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts
new file mode 100644
index 000000000..261a0b90f
--- /dev/null
+++ b/tests/unit/timezone-date-time.test.ts
@@ -0,0 +1,290 @@
+import { startOfDay } from 'date-fns'
+import { describe, expect, it, vi } from 'vitest'
+import {
+ dateTimePartsToUtcDate,
+ dateTimePartsToUtcTimestamp,
+ formatDate,
+ formatDateParts,
+ formatDateRange,
+ formatDateTimeInput,
+ formatTimezoneAbbreviation,
+ getRelativeDay,
+ instantToCalendarDate,
+} from '@/lib/utils/formatting'
+import { requireTimezone } from './helpers/timezone'
+
+describe('timezone date-time helpers', () => {
+ const newYork = requireTimezone('America/New_York')
+ const berlin = requireTimezone('Europe/Berlin')
+ const utc = requireTimezone('UTC')
+
+ describe('formatDateTimeInput', () => {
+ it('formats a UTC timestamp into New York picker parts', () => {
+ const result = formatDateTimeInput('2026-06-08T13:05:09.000Z', newYork)
+
+ expect(result).toEqual({
+ date: '2026/06/08',
+ time: '09:05:09',
+ })
+ })
+
+ it('formats a UTC timestamp into Berlin picker parts', () => {
+ const result = formatDateTimeInput('2026-06-08T13:05:09.000Z', berlin)
+
+ expect(result).toEqual({
+ date: '2026/06/08',
+ time: '15:05:09',
+ })
+ })
+ })
+
+ describe('instantToCalendarDate', () => {
+ it('maps an instant to a local calendar date from dashboard wall-clock parts', () => {
+ const calendarDate = instantToCalendarDate(
+ '2023-01-01T00:00:00.000Z',
+ utc
+ )
+
+ expect(calendarDate.getFullYear()).toBe(2023)
+ expect(calendarDate.getMonth()).toBe(0)
+ expect(calendarDate.getDate()).toBe(1)
+ })
+
+ it('uses the dashboard timezone instead of browser-local day boundaries', () => {
+ const minBound = new Date('2023-01-01T00:00:00.000Z')
+ const calendarMin = instantToCalendarDate(minBound, utc)
+
+ vi.stubEnv('TZ', 'America/Los_Angeles')
+ const browserLocalMin = startOfDay(minBound)
+ vi.unstubAllEnvs()
+
+ expect(browserLocalMin.getFullYear()).toBe(2022)
+ expect(browserLocalMin.getMonth()).toBe(11)
+ expect(browserLocalMin.getDate()).toBe(31)
+
+ expect(calendarMin.getFullYear()).toBe(2023)
+ expect(calendarMin.getMonth()).toBe(0)
+ expect(calendarMin.getDate()).toBe(1)
+ })
+
+ it('maps max-bound instants to the dashboard wall-clock calendar day', () => {
+ const calendarMax = instantToCalendarDate('2026-02-24T18:17:41.000Z', utc)
+
+ expect(calendarMax.getFullYear()).toBe(2026)
+ expect(calendarMax.getMonth()).toBe(1)
+ expect(calendarMax.getDate()).toBe(24)
+ })
+ })
+
+ describe('dateTimePartsToUtcDate', () => {
+ it('converts New York wall-clock parts to UTC', () => {
+ const result = dateTimePartsToUtcDate(
+ {
+ year: 2026,
+ month: 6,
+ day: 8,
+ hours: 9,
+ minutes: 0,
+ seconds: 0,
+ },
+ newYork
+ )
+
+ expect(result.toISOString()).toBe('2026-06-08T13:00:00.000Z')
+ })
+
+ it('converts UTC wall-clock parts without offset changes', () => {
+ const result = dateTimePartsToUtcTimestamp(
+ {
+ year: 2026,
+ month: 6,
+ day: 8,
+ hours: 9,
+ minutes: 0,
+ seconds: 0,
+ },
+ utc
+ )
+
+ expect(result).toBe(Date.UTC(2026, 5, 8, 9, 0, 0))
+ })
+ })
+
+ describe('formatTimezoneAbbreviation', () => {
+ it('formats daylight saving and standard abbreviations', () => {
+ expect(
+ formatTimezoneAbbreviation('2026-06-08T13:00:00.000Z', newYork)
+ ).toBe('EDT')
+ expect(
+ formatTimezoneAbbreviation('2026-01-08T14:00:00.000Z', newYork)
+ ).toBe('EST')
+ })
+ })
+
+ describe('formatDateRange', () => {
+ it('formats a date-only range in the selected timezone', () => {
+ const result = formatDateRange(
+ '2026-06-08T13:00:00.000Z',
+ '2026-06-09T13:00:00.000Z',
+ { timezone: newYork }
+ )
+
+ expect(result).toBe('Jun 8, 2026 - Jun 9, 2026 EDT')
+ })
+
+ it('formats a timestamp range in the selected timezone', () => {
+ const result = formatDateRange(
+ '2026-06-08T13:00:00.000Z',
+ '2026-06-09T13:00:00.000Z',
+ { timezone: newYork, format: 'date-time-padded-hour' }
+ )
+
+ expect(result).toBe(
+ 'Jun 8, 2026 at 09:00:00 AM - Jun 9, 2026 at 09:00:00 AM EDT'
+ )
+ })
+ })
+
+ describe('formatDate compact-timestamp preset', () => {
+ it('formats compact timestamps in the selected timezone', () => {
+ const timestamp = Date.UTC(2026, 5, 8, 13, 0, 0)
+ const formatted = formatDate(timestamp, {
+ timezone: newYork,
+ format: 'compact-timestamp',
+ })
+
+ expect(formatted).toContain('Jun 8')
+ expect(formatted).toContain('9:00:00')
+ })
+ })
+
+ describe('formatDate', () => {
+ it('formats date-only labels in the selected timezone', () => {
+ expect(
+ formatDate('2026-06-08T13:00:00.000Z', { timezone: newYork })
+ ).toBe('Jun 8, 2026')
+ expect(
+ formatDate('2026-06-08T13:00:00.000Z', {
+ timezone: newYork,
+ format: 'date',
+ })
+ ).toBe('Jun 8, 2026')
+ })
+
+ it('formats time labels in the selected timezone', () => {
+ expect(
+ formatDate('2026-06-08T13:05:09.000Z', {
+ timezone: newYork,
+ format: 'time',
+ })
+ ).toBe('9:05:09 AM')
+ })
+
+ it('formats 24-hour time labels in the selected timezone', () => {
+ expect(
+ formatDate('2026-06-08T13:05:09.000Z', {
+ timezone: newYork,
+ format: 'time-24h-no-seconds',
+ })
+ ).toBe('09:05')
+ expect(
+ formatDate('2026-06-08T13:05:09.000Z', {
+ timezone: newYork,
+ format: 'time-24h',
+ })
+ ).toBe('09:05:09')
+ })
+
+ it('formats exact tooltip timestamps in the selected timezone', () => {
+ const result = formatDate('2026-06-08T13:00:00.000Z', {
+ timezone: newYork,
+ format: 'exact-timestamp',
+ })
+
+ expect(result).toContain('2026-06-08')
+ expect(result).toContain('09:00:00')
+ expect(result).toContain('EDT')
+ })
+ })
+
+ describe('formatDate time-with-centiseconds preset', () => {
+ it('formats time with centiseconds in the selected timezone', () => {
+ const result = formatDate('2026-06-08T13:05:09.870Z', {
+ timezone: newYork,
+ format: 'time-with-centiseconds',
+ })
+
+ expect(result).toContain('09:05:09.87')
+ expect(result?.toLowerCase()).toMatch(/am|pm/)
+ })
+ })
+
+ describe('formatDateParts', () => {
+ it('formats date-time parts in the selected timezone', () => {
+ const result = formatDateParts('2026-06-08T13:05:09.870Z', {
+ timezone: newYork,
+ })
+
+ expect(result).toMatchObject({
+ datePart: expect.stringMatching(/Jun 08/),
+ timePart: expect.stringMatching(/09:05:09/),
+ subsecondPart: null,
+ timezonePart: 'EDT',
+ iso: '2026-06-08T13:05:09.870Z',
+ })
+ })
+
+ it('formats date-time-with-centiseconds parts in the selected timezone', () => {
+ const result = formatDateParts('2026-06-08T13:05:09.870Z', {
+ timezone: newYork,
+ format: 'date-time-with-centiseconds',
+ })
+
+ expect(result).toMatchObject({
+ datePart: expect.stringMatching(/Jun 08/),
+ timePart: expect.stringMatching(/09:05:09/),
+ subsecondPart: '87',
+ timezonePart: 'EDT',
+ iso: '2026-06-08T13:05:09.870Z',
+ })
+ })
+
+ it('formats date-year-time-no-seconds parts in the selected timezone', () => {
+ const result = formatDateParts('2026-06-08T13:05:09.870Z', {
+ timezone: newYork,
+ format: 'date-year-time-no-seconds',
+ })
+
+ expect(result).toMatchObject({
+ datePart: expect.stringMatching(/Jun 08, 2026/),
+ timePart: expect.stringMatching(/09:05/),
+ subsecondPart: null,
+ timezonePart: 'EDT',
+ iso: '2026-06-08T13:05:09.870Z',
+ })
+ expect(result?.timePart).not.toMatch(/09:05:09/)
+ })
+
+ it('returns null for invalid timestamps', () => {
+ expect(
+ formatDateParts('invalid', {
+ timezone: newYork,
+ })
+ ).toBeNull()
+ })
+ })
+
+ describe('getRelativeDay', () => {
+ it('labels today and yesterday in the selected timezone', () => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-06-10T16:00:00.000Z'))
+
+ expect(getRelativeDay('2026-06-10T16:00:00.000Z', newYork)).toBe('Today')
+ expect(getRelativeDay('2026-06-09T16:00:00.000Z', newYork)).toBe(
+ 'Yesterday'
+ )
+
+ vi.useRealTimers()
+ })
+ })
+})
diff --git a/tests/unit/timezone-utils.test.ts b/tests/unit/timezone-utils.test.ts
new file mode 100644
index 000000000..55cd7e874
--- /dev/null
+++ b/tests/unit/timezone-utils.test.ts
@@ -0,0 +1,84 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ formatTimezoneLabel,
+ getBrowserTimezone,
+ getTimezones,
+ isValidTimezone,
+ parseTimezone,
+} from '@/features/dashboard/timezone/utils'
+
+describe('timezone utils', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-01-15T12:00:00Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ describe('isValidTimezone', () => {
+ it('accepts valid IANA timezones and UTC', () => {
+ expect(isValidTimezone('America/New_York')).toBe(true)
+ expect(isValidTimezone('Europe/Berlin')).toBe(true)
+ expect(isValidTimezone('UTC')).toBe(true)
+ })
+
+ it('rejects invalid timezone values', () => {
+ expect(isValidTimezone('not-a-timezone')).toBe(false)
+ expect(isValidTimezone('')).toBe(false)
+ })
+ })
+
+ describe('parseTimezone', () => {
+ it('returns the timezone when it is valid', () => {
+ expect(parseTimezone('America/New_York')).toBe('America/New_York')
+ })
+
+ it('returns null for missing or invalid timezone values', () => {
+ expect(parseTimezone(null)).toBeNull()
+ expect(parseTimezone(undefined)).toBeNull()
+ expect(parseTimezone('not-a-timezone')).toBeNull()
+ })
+ })
+
+ describe('getTimezones', () => {
+ it('returns valid timezone options', () => {
+ const options = getTimezones()
+
+ expect(options.length).toBeGreaterThan(0)
+ expect(options.every(isValidTimezone)).toBe(true)
+ })
+
+ it('includes the browser timezone', () => {
+ const options = getTimezones()
+
+ expect(options).toContain(getBrowserTimezone())
+ })
+
+ it('includes UTC even when Intl.supportedValuesOf omits it', () => {
+ const options = getTimezones()
+
+ expect(options).toContain('UTC')
+ })
+ })
+
+ describe('formatTimezoneLabel', () => {
+ it('includes the timezone and its short display name', () => {
+ const timezone = parseTimezone('America/New_York')
+ if (!timezone) throw new Error('Expected valid timezone')
+
+ const label = formatTimezoneLabel(timezone)
+
+ expect(label).toContain('America/New York')
+ expect(label).toContain('EST')
+ })
+
+ it('replaces underscores with spaces for display', () => {
+ const timezone = parseTimezone('Africa/Addis_Ababa')
+ if (!timezone) throw new Error('Expected valid timezone')
+
+ expect(formatTimezoneLabel(timezone)).toContain('Africa/Addis Ababa')
+ })
+ })
+})
diff --git a/tests/unit/usage-display-utils.test.ts b/tests/unit/usage-display-utils.test.ts
new file mode 100644
index 000000000..9ec298103
--- /dev/null
+++ b/tests/unit/usage-display-utils.test.ts
@@ -0,0 +1,62 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import {
+ formatAxisDate,
+ formatHoveredValues,
+} from '@/features/dashboard/usage/display-utils'
+import { requireTimezone } from './helpers/timezone'
+
+describe('usage display utilities', () => {
+ const newYork = requireTimezone('America/New_York')
+ const losAngeles = requireTimezone('America/Los_Angeles')
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-06-10T16:00:00.000Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('formats hourly axis labels in the selected timezone', () => {
+ const timestamp = Date.UTC(2026, 5, 8, 13, 0, 0)
+
+ expect(formatAxisDate(timestamp, 'hourly', newYork)).toContain('9 AM')
+ expect(formatAxisDate(timestamp, 'hourly', losAngeles)).toContain('6 AM')
+ })
+
+ it('formats hover timestamps in the selected timezone', () => {
+ const timestamp = Date.UTC(2026, 5, 8, 13, 0, 0)
+ const timeframe = {
+ start: timestamp,
+ end: timestamp + 60 * 60 * 1000,
+ }
+
+ expect(
+ formatHoveredValues(1, 2, 3, 4, timestamp, timeframe, newYork).sandboxes
+ .timestamp
+ ).toBe('Jun 8, 9am')
+ expect(
+ formatHoveredValues(1, 2, 3, 4, timestamp, timeframe, losAngeles)
+ .sandboxes.timestamp
+ ).toBe('Jun 8, 6am')
+ })
+
+ it('formats partial daily edge buckets using the actual range boundary hour', () => {
+ const timeframe = {
+ start: Date.UTC(2026, 5, 1, 13, 0, 0),
+ end: Date.UTC(2026, 5, 8, 15, 0, 0),
+ }
+ const startBucket = Date.UTC(2026, 5, 1, 4, 0, 0)
+ const endBucket = Date.UTC(2026, 5, 8, 4, 0, 0)
+
+ expect(
+ formatHoveredValues(1, 2, 3, 4, startBucket, timeframe, newYork).sandboxes
+ .timestamp
+ ).toBe('Jun 1, 9am - end of Jun 1')
+ expect(
+ formatHoveredValues(1, 2, 3, 4, endBucket, timeframe, newYork).sandboxes
+ .timestamp
+ ).toBe('Jun 8 - Jun 8, 11am')
+ })
+})
diff --git a/tests/unit/usage-sampling-utils.test.ts b/tests/unit/usage-sampling-utils.test.ts
new file mode 100644
index 000000000..a7605fe88
--- /dev/null
+++ b/tests/unit/usage-sampling-utils.test.ts
@@ -0,0 +1,84 @@
+import { describe, expect, it } from 'vitest'
+import {
+ normalizeToEndOfSamplingPeriod,
+ normalizeToStartOfSamplingPeriod,
+ processUsageData,
+} from '@/features/dashboard/usage/sampling-utils'
+import { requireTimezone } from './helpers/timezone'
+
+describe('usage sampling utilities', () => {
+ const newYork = requireTimezone('America/New_York')
+ const losAngeles = requireTimezone('America/Los_Angeles')
+
+ it('normalizes daily boundaries in the selected timezone', () => {
+ const timestamp = Date.UTC(2026, 5, 10, 13, 0, 0)
+
+ expect(
+ new Date(
+ normalizeToStartOfSamplingPeriod(timestamp, 'daily', newYork)
+ ).toISOString()
+ ).toBe('2026-06-10T04:00:00.000Z')
+ expect(
+ new Date(
+ normalizeToStartOfSamplingPeriod(timestamp, 'daily', losAngeles)
+ ).toISOString()
+ ).toBe('2026-06-10T07:00:00.000Z')
+
+ expect(
+ new Date(
+ normalizeToEndOfSamplingPeriod(timestamp, 'daily', newYork)
+ ).toISOString()
+ ).toBe('2026-06-11T03:59:59.999Z')
+ expect(
+ new Date(
+ normalizeToEndOfSamplingPeriod(timestamp, 'daily', losAngeles)
+ ).toISOString()
+ ).toBe('2026-06-11T06:59:59.999Z')
+ })
+
+ it('normalizes weekly boundaries in the selected timezone', () => {
+ const timestamp = Date.UTC(2026, 5, 10, 13, 0, 0)
+
+ expect(
+ new Date(
+ normalizeToStartOfSamplingPeriod(timestamp, 'weekly', newYork)
+ ).toISOString()
+ ).toBe('2026-06-08T04:00:00.000Z')
+ expect(
+ new Date(
+ normalizeToEndOfSamplingPeriod(timestamp, 'weekly', newYork)
+ ).toISOString()
+ ).toBe('2026-06-15T03:59:59.999Z')
+ })
+
+ it('aggregates daily data into selected-timezone bucket starts', () => {
+ const timestamp = Date.UTC(2026, 5, 10, 13, 0, 0)
+ const sampledData = processUsageData(
+ [
+ {
+ timestamp,
+ sandbox_count: 2,
+ cpu_hours: 3,
+ ram_gib_hours: 4,
+ price_for_cpu: 5,
+ price_for_ram: 6,
+ },
+ ],
+ {
+ start: Date.UTC(2026, 5, 1, 0, 0, 0),
+ end: Date.UTC(2026, 5, 11, 0, 0, 0),
+ },
+ losAngeles
+ )
+
+ expect(sampledData).toEqual([
+ {
+ timestamp: Date.UTC(2026, 5, 10, 7, 0, 0),
+ sandboxCount: 2,
+ cost: 11,
+ vcpuHours: 3,
+ ramGibHours: 4,
+ },
+ ])
+ })
+})
diff --git a/tests/unit/usage-time-range-presets.test.ts b/tests/unit/usage-time-range-presets.test.ts
new file mode 100644
index 000000000..463cbc9af
--- /dev/null
+++ b/tests/unit/usage-time-range-presets.test.ts
@@ -0,0 +1,51 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import type { Timezone } from '@/features/dashboard/timezone'
+import { getUsageTimeRangePresets } from '@/features/dashboard/usage/constants'
+import { formatDateTimeInput } from '@/lib/utils/formatting'
+import { requireTimezone } from './helpers/timezone'
+
+const getPresetRange = (timezone: Timezone, presetId: string) => {
+ const preset = getUsageTimeRangePresets(timezone).find(
+ (option) => option.id === presetId
+ )
+ if (!preset) throw new Error(`Expected ${presetId} preset to exist`)
+
+ return preset.getValue()
+}
+
+describe('usage time range presets', () => {
+ const newYork = requireTimezone('America/New_York')
+ const losAngeles = requireTimezone('America/Los_Angeles')
+
+ beforeEach(() => {
+ vi.useFakeTimers()
+ vi.setSystemTime(new Date('2026-06-10T16:00:00.000Z'))
+ })
+
+ afterEach(() => {
+ vi.useRealTimers()
+ })
+
+ it('computes last 90 days from midnight to end of day in the selected timezone', () => {
+ const newYorkRange = getPresetRange(newYork, 'last-90-days')
+ const losAngelesRange = getPresetRange(losAngeles, 'last-90-days')
+
+ expect(formatDateTimeInput(newYorkRange.start, newYork)).toEqual({
+ date: '2026/03/13',
+ time: '00:00:00',
+ })
+ expect(formatDateTimeInput(newYorkRange.end, newYork)).toEqual({
+ date: '2026/06/10',
+ time: '23:59:59',
+ })
+
+ expect(formatDateTimeInput(losAngelesRange.start, losAngeles)).toEqual({
+ date: '2026/03/13',
+ time: '00:00:00',
+ })
+ expect(formatDateTimeInput(losAngelesRange.end, losAngeles)).toEqual({
+ date: '2026/06/10',
+ time: '23:59:59',
+ })
+ })
+})