-
-
-
-
- {children}
-
-
+
+
+
+
+
+
+
+ {children}
+
+
+
-
-
+
+
)
diff --git a/src/configs/cookies.ts b/src/configs/cookies.ts
index ac7e9e1e9..f6450c9a5 100644
--- a/src/configs/cookies.ts
+++ b/src/configs/cookies.ts
@@ -11,6 +11,8 @@ export const COOKIE_KEYS = {
SIDEBAR_STATE: 'e2b-sidebar-state',
SANDBOX_INSPECT_ROOT_PATH: 'e2b-sandbox-inspect-root-path',
+
+ DASHBOARD_TIMEZONE: 'e2b-dashboard-timezone',
} as const
const BASE_COOKIE_OPTIONS: Partial
= {
@@ -37,4 +39,7 @@ export const COOKIE_OPTIONS = {
[COOKIE_KEYS.SANDBOX_INSPECT_ROOT_PATH]: {
...BASE_COOKIE_OPTIONS,
},
+ [COOKIE_KEYS.DASHBOARD_TIMEZONE]: {
+ ...BASE_COOKIE_OPTIONS,
+ },
} as const
diff --git a/src/features/dashboard/account/timezone-settings.tsx b/src/features/dashboard/account/timezone-settings.tsx
new file mode 100644
index 000000000..ac35f62c6
--- /dev/null
+++ b/src/features/dashboard/account/timezone-settings.tsx
@@ -0,0 +1,153 @@
+'use client'
+
+import { useMemo, useState } from 'react'
+import {
+ formatTimezoneLabel,
+ getBrowserTimezone,
+ getTimezones,
+ type Timezone,
+ useTimezone,
+} from '@/features/dashboard/timezone'
+import {
+ defaultErrorToast,
+ defaultSuccessToast,
+ useToast,
+} from '@/lib/hooks/use-toast'
+import { cn } from '@/lib/utils'
+import { Button } from '@/ui/primitives/button'
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardFooter,
+ CardHeader,
+ CardTitle,
+} from '@/ui/primitives/card'
+import {
+ Command,
+ CommandEmpty,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from '@/ui/primitives/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/ui/primitives/popover'
+
+interface TimezoneSettingsProps {
+ className?: string
+}
+
+export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => {
+ const { timezone, setTimezone } = useTimezone()
+ const { toast } = useToast()
+ const [open, setOpen] = useState(false)
+ const [isSaving, setIsSaving] = useState(false)
+
+ const browserTimezone = useMemo(() => getBrowserTimezone(), [])
+ const timezoneOptions = useMemo(
+ () =>
+ getTimezones().map((option) => ({
+ value: option,
+ label: formatTimezoneLabel(option),
+ })),
+ []
+ )
+ const timezoneLabel = useMemo(() => formatTimezoneLabel(timezone), [timezone])
+ const browserTimezoneLabel = useMemo(
+ () => formatTimezoneLabel(browserTimezone),
+ [browserTimezone]
+ )
+ const isBrowserTimezoneSelected = timezone === browserTimezone
+
+ const handleTimezoneSelect = async (nextTimezone: Timezone) => {
+ if (nextTimezone === timezone) {
+ setOpen(false)
+ return
+ }
+
+ setIsSaving(true)
+ const didSave = await setTimezone(nextTimezone)
+ setIsSaving(false)
+
+ if (!didSave) {
+ toast(defaultErrorToast('Failed to update timezone preference.'))
+ return
+ }
+
+ toast(defaultSuccessToast('Timezone updated.'))
+ setOpen(false)
+ }
+
+ return (
+
+
+ Timezone
+
+ Choose how dashboard time ranges, charts, and timestamp labels should
+ be displayed.
+
+
+
+
+
+
+
+
+
+
+
+
+ No timezones found.
+ {timezoneOptions.map((option) => (
+ void handleTimezoneSelect(option.value)}
+ className="justify-between"
+ >
+ {option.label}
+ {option.value === timezone ? (
+
+ Selected
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+
+
+
+ Browser timezone:{' '}
+ {browserTimezoneLabel}
+
+ {!isBrowserTimezoneSelected ? (
+
+ ) : null}
+
+
+ )
+}
diff --git a/src/features/dashboard/timezone/context.tsx b/src/features/dashboard/timezone/context.tsx
new file mode 100644
index 000000000..c3e4dce76
--- /dev/null
+++ b/src/features/dashboard/timezone/context.tsx
@@ -0,0 +1,103 @@
+'use client'
+
+import {
+ createContext,
+ type ReactNode,
+ useCallback,
+ useContext,
+ useEffect,
+ useMemo,
+ useState,
+} from 'react'
+import { getBrowserTimezone, parseTimezone, type Timezone } from './utils'
+
+interface TimezoneContextValue {
+ timezone: Timezone
+ setTimezone: (timezone: string) => 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 ?? getBrowserTimezone()
+ )
+
+ useEffect(() => {
+ if (parsedInitialTimezone) return
+
+ const browserTimezone = getBrowserTimezone()
+ setTimezoneState(browserTimezone)
+ void persistTimezone(browserTimezone)
+ }, [parsedInitialTimezone])
+
+ const setTimezone = useCallback(
+ async (nextTimezone: string) => {
+ const parsedTimezone = parseTimezone(nextTimezone)
+ if (!parsedTimezone) return false
+
+ const previousTimezone = timezone
+ setTimezoneState(parsedTimezone)
+
+ const didPersist = await persistTimezone(parsedTimezone)
+ 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..a3f3d2af5
--- /dev/null
+++ b/src/features/dashboard/timezone/utils.ts
@@ -0,0 +1,74 @@
+import { type Timezone, TimezoneSchema } from './schema'
+
+// Returns true when an IANA timezone is valid; e.g. "America/New_York" -> true.
+const isValidTimezone = (timezone: string): timezone is Timezone =>
+ TimezoneSchema.safeParse(timezone).success
+
+// Parses a timezone preference safely; e.g. "Europe/Berlin" -> "Europe/Berlin".
+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
+}
+
+// Returns the browser timezone with a safe fallback; e.g. browser in New York -> "America/New_York".
+const getBrowserTimezone = (): Timezone => {
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
+ const parsedTimezone = parseTimezone(timezone)
+ if (parsedTimezone) return parsedTimezone
+
+ const utcTimezone = TimezoneSchema.safeParse('UTC')
+ if (utcTimezone.success) return utcTimezone.data
+
+ throw new Error('Unable to resolve browser timezone')
+}
+
+// Returns supported IANA timezone options; e.g. first option can be "Africa/Abidjan".
+const getTimezones = (): Timezone[] => {
+ const browserTimezone = getBrowserTimezone()
+
+ if (typeof Intl.supportedValuesOf === 'function') {
+ const timezones = Intl.supportedValuesOf('timeZone')
+ if (timezones.length > 0) {
+ return Array.from(new Set([browserTimezone, ...timezones]))
+ .filter(isValidTimezone)
+ .sort()
+ }
+ }
+
+ return [browserTimezone]
+}
+
+// Formats a timezone for display; e.g. "America/New_York" -> "America/New York".
+const formatTimezoneDisplayName = (timezone: Timezone): string =>
+ timezone.replaceAll('_', ' ')
+
+// Formats a timezone label with its current short name; e.g. "America/New_York" -> "America/New York (EST)".
+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/tests/unit/timezone-utils.test.ts b/tests/unit/timezone-utils.test.ts
new file mode 100644
index 000000000..8711ff6eb
--- /dev/null
+++ b/tests/unit/timezone-utils.test.ts
@@ -0,0 +1,78 @@
+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())
+ })
+ })
+
+ 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')
+ })
+ })
+})