Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions src/app/api/timezone/state/route.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
}
3 changes: 3 additions & 0 deletions src/app/dashboard/[teamSlug]/account/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -21,6 +22,8 @@ export default async function AccountPage({
<AccessTokenSettings />

<PasswordSettingsServer searchParams={searchParams} />

<TimezoneSettings />
</div>
)
}
35 changes: 22 additions & 13 deletions src/app/dashboard/[teamSlug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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)
Expand All @@ -62,20 +67,24 @@ export default async function DashboardLayout({
return (
<HydrateClient>
<DashboardTeamGate teamSlug={teamSlug} fallbackUser={authContext.user}>
<SidebarProvider
defaultOpen={typeof sidebarState === 'undefined' ? true : defaultOpen}
>
<div className="fixed inset-0 flex max-h-full min-h-0 w-full flex-col overflow-hidden">
<div className="flex h-full max-h-full min-h-0 w-full flex-1 overflow-hidden">
<Sidebar />
<SidebarInset>
<DashboardLayoutView params={params}>
{children}
</DashboardLayoutView>
</SidebarInset>
<TimezoneProvider initialTimezone={timezone}>
<SidebarProvider
defaultOpen={
typeof sidebarState === 'undefined' ? true : defaultOpen
}
>
<div className="fixed inset-0 flex max-h-full min-h-0 w-full flex-col overflow-hidden">
<div className="flex h-full max-h-full min-h-0 w-full flex-1 overflow-hidden">
<Sidebar />
<SidebarInset>
<DashboardLayoutView params={params}>
{children}
</DashboardLayoutView>
</SidebarInset>
</div>
</div>
</div>
</SidebarProvider>
</SidebarProvider>
</TimezoneProvider>
</DashboardTeamGate>
</HydrateClient>
)
Expand Down
5 changes: 5 additions & 0 deletions src/configs/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ResponseCookie> = {
Expand All @@ -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
153 changes: 153 additions & 0 deletions src/features/dashboard/account/timezone-settings.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Card
className={cn('overflow-hidden border-b md:border', className)}
hideUnderline
>
<CardHeader>
<CardTitle>Timezone</CardTitle>
<CardDescription>
Choose how dashboard time ranges, charts, and timestamp labels should
be displayed.
</CardDescription>
</CardHeader>

<CardContent className="flex flex-col gap-3">
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
type="button"
variant="secondary"
loading={isSaving ? 'Saving...' : undefined}
className="w-full max-w-[24rem] justify-between font-mono"
>
{timezoneLabel}
</Button>
</PopoverTrigger>
<PopoverContent className="w-[24rem] max-w-[calc(100vw-2rem)] p-0">
<Command>
<CommandInput placeholder="Search timezones..." />
<CommandList>
<CommandEmpty>No timezones found.</CommandEmpty>
{timezoneOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
onSelect={() => void handleTimezoneSelect(option.value)}
className="justify-between"
>
<span>{option.label}</span>
{option.value === timezone ? (
<span className="text-accent-main-highlight">
Selected
</span>
) : null}
</CommandItem>
))}
</CommandList>
</Command>
</PopoverContent>
</Popover>
</CardContent>

<CardFooter className="bg-bg-1 justify-between gap-3">
<p className="text-fg-tertiary">
Browser timezone:{' '}
<span className="font-mono">{browserTimezoneLabel}</span>
</p>
{!isBrowserTimezoneSelected ? (
<Button
type="button"
variant="secondary"
loading={isSaving ? 'Saving...' : undefined}
onClick={() => void handleTimezoneSelect(browserTimezone)}
>
Use browser timezone
</Button>
) : null}
</CardFooter>
</Card>
)
}
103 changes: 103 additions & 0 deletions src/features/dashboard/timezone/context.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>
}

interface TimezoneProviderProps {
children: ReactNode
initialTimezone: string | null
}

const TimezoneContext = createContext<TimezoneContextValue | null>(null)

const persistTimezone = async (timezone: Timezone): Promise<boolean> => {
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 (
<TimezoneContext.Provider value={value}>
{children}
</TimezoneContext.Provider>
)
}

export const useTimezone = () => {
const context = useContext(TimezoneContext)
if (!context) {
throw new Error('useTimezone must be used within TimezoneProvider')
}

return context
}
10 changes: 10 additions & 0 deletions src/features/dashboard/timezone/index.ts
Original file line number Diff line number Diff line change
@@ -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'
Loading
Loading