diff --git a/src/app/api/timezone/state/route.ts b/src/app/api/timezone/state/route.ts new file mode 100644 index 000000000..f36b32089 --- /dev/null +++ b/src/app/api/timezone/state/route.ts @@ -0,0 +1,28 @@ +import { cookies } from 'next/headers' +import { z } from 'zod' +import { COOKIE_KEYS, COOKIE_OPTIONS } from '@/configs/cookies' +import { TimezoneSchema } from '@/features/dashboard/timezone/schema' + +const TimezoneStateSchema = z.object({ + timezone: TimezoneSchema, +}) + +export const POST = async (request: Request) => { + try { + const result = TimezoneStateSchema.safeParse(await request.json()) + if (!result.success) { + return Response.json({ error: 'Invalid request' }, { status: 400 }) + } + + const cookieStore = await cookies() + cookieStore.set( + COOKIE_KEYS.DASHBOARD_TIMEZONE, + result.data.timezone, + COOKIE_OPTIONS[COOKIE_KEYS.DASHBOARD_TIMEZONE] + ) + + return Response.json({ timezone: result.data.timezone }) + } catch { + return Response.json({ error: 'Invalid request' }, { status: 400 }) + } +} diff --git a/src/app/dashboard/[teamSlug]/account/page.tsx b/src/app/dashboard/[teamSlug]/account/page.tsx index 9b2e0180a..3cefacfb7 100644 --- a/src/app/dashboard/[teamSlug]/account/page.tsx +++ b/src/app/dashboard/[teamSlug]/account/page.tsx @@ -2,6 +2,7 @@ import { AccessTokenSettings } from '@/features/dashboard/account/access-token-s import { EmailSettings } from '@/features/dashboard/account/email-settings' import { NameSettings } from '@/features/dashboard/account/name-settings' import { PasswordSettingsServer } from '@/features/dashboard/account/password-settings-server' +import { TimezoneSettings } from '@/features/dashboard/account/timezone-settings' export interface AccountPageSearchParams { reauth?: '1' @@ -21,6 +22,8 @@ export default async function AccountPage({ + + ) } diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 79768cc6e..8a7481592 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -10,6 +10,8 @@ import { DASHBOARD_USER_PROFILE_QUERY_OPTIONS } from '@/core/application/user/qu import { auth } from '@/core/server/auth' import DashboardLayoutView from '@/features/dashboard/layouts/layout' import Sidebar from '@/features/dashboard/sidebar/sidebar' +import { TimezoneProvider } from '@/features/dashboard/timezone/context' +import { parseTimezone } from '@/features/dashboard/timezone/utils' import { HydrateClient, prefetchAsync, trpc } from '@/trpc/server' import { SidebarInset, SidebarProvider } from '@/ui/primitives/sidebar' @@ -39,6 +41,9 @@ export default async function DashboardLayout({ const sidebarState = cookieStore.get(COOKIE_KEYS.SIDEBAR_STATE)?.value const defaultOpen = sidebarState === 'true' + const timezone = parseTimezone( + cookieStore.get(COOKIE_KEYS.DASHBOARD_TIMEZONE)?.value + ) if (!authContext) { throw redirect(AUTH_URLS.SIGN_IN) @@ -62,20 +67,24 @@ export default async function DashboardLayout({ return ( - -
-
- - - - {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') + }) + }) +})