Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
2ffe1dd
feat(timezone): implement timezone management in dashboard
sarimrmalik Jun 8, 2026
10ed179
Update toast copy
sarimrmalik Jun 8, 2026
2398887
fix(timezone): apply dashboard timezone to time range pickers
sarimrmalik Jun 10, 2026
1663db2
feat(timezone): enhance time picker functionality with timezone support
sarimrmalik Jun 10, 2026
b81f894
feat(timezone): integrate timezone support in usage display utilities
sarimrmalik Jun 11, 2026
f7460dc
Complete dashboard timezone display coverage
sarimrmalik Jun 11, 2026
ad0afc2
Address timezone review feedback
sarimrmalik Jun 11, 2026
83f3a28
Fix timezone settings hydration
sarimrmalik Jun 11, 2026
f0038ef
Refine timezone range formatting
sarimrmalik Jun 11, 2026
e250b37
Ensure UTC appears in timezone options
sarimrmalik Jun 11, 2026
20a3db2
Require auth for timezone preference updates
sarimrmalik Jun 11, 2026
cf3f64d
Apply dashboard timezone to remaining timestamps
sarimrmalik Jun 11, 2026
06bc3be
Align picker bounds with dashboard timezone
sarimrmalik Jun 11, 2026
6ac9318
fix(timezone): satisfy TypeScript in zonedInstantToCalendarDate
sarimrmalik Jun 11, 2026
80de80a
refactor(timezone): centralize zoned date helpers and simplify picker…
sarimrmalik Jun 11, 2026
5f61733
refactor(timezone): remove unused formatters and dedupe monitoring da…
sarimrmalik Jun 11, 2026
3abd613
fix(timezone): normalize zoned part inputs before formatToParts
sarimrmalik Jun 11, 2026
493eb4d
refactor(timezone): unify date formatting behind formatDate options API
sarimrmalik Jun 11, 2026
0e6edb6
refactor(timezone): use named date format presets
sarimrmalik Jun 11, 2026
5c2c91d
refactor(timezone): remove padded date preset
sarimrmalik Jun 11, 2026
62fe060
Fold centisecond time into date presets
sarimrmalik Jun 11, 2026
378b3b1
Centralize date part formatting
sarimrmalik Jun 11, 2026
be2a6ab
Split relative day formatting
sarimrmalik Jun 12, 2026
1d47861
Use centralized time formatting
sarimrmalik Jun 12, 2026
0b73253
Simplify log timestamp rendering
sarimrmalik Jun 12, 2026
d9d0af5
Consolidate time display presets
sarimrmalik Jun 12, 2026
4bfd312
Centralize axis time formats
sarimrmalik Jun 12, 2026
b47b991
Align date range formatting
sarimrmalik Jun 12, 2026
993821f
Clarify date range presets
sarimrmalik Jun 12, 2026
72ea104
Localize axis label strategy
sarimrmalik Jun 12, 2026
7db5c29
Rename axis label formatter
sarimrmalik Jun 12, 2026
4a60f0d
Rename date format type
sarimrmalik Jun 12, 2026
eb041b0
Tighten timezone setter type
sarimrmalik Jun 12, 2026
6f7db68
Trim timezone type exports
sarimrmalik Jun 12, 2026
48593f2
Remove zoned helper prefixes
sarimrmalik Jun 12, 2026
556a7a1
style: apply biome formatting
sarimrmalik Jun 12, 2026
0c642be
refactor(timezone): reorganize timezone imports and utilities
sarimrmalik Jun 15, 2026
215cf1b
feat(timezone): add button to use browser timezone if not selected
sarimrmalik Jun 15, 2026
1670863
Keep timezone popover stable
sarimrmalik Jun 16, 2026
a927cfc
Polish timezone save interaction
sarimrmalik Jun 16, 2026
02e9486
style: apply biome formatting
sarimrmalik Jun 16, 2026
8c6086b
Keep timestamp formatting stable
sarimrmalik Jun 29, 2026
867bfdc
Align calendar bounds with timezone
sarimrmalik Jun 29, 2026
77a935f
Fix dashboard preview build
sarimrmalik Jun 29, 2026
d32398b
refactor(timezone): route dashboard labels through shared formatting
sarimrmalik Jun 29, 2026
80bbbd6
fix: pin timezon locale formatting to US
drankou Jul 9, 2026
1817b4d
fix: dynamic timestamp-column for tables based on timezone
drankou Jul 9, 2026
02f5f3d
fix: add timezone header timestamps (sandbox, webhook)
drankou Jul 9, 2026
fc45379
fix: template overview timestamp
drankou Jul 9, 2026
cfd4a67
chore: cleanup unused import
drankou Jul 9, 2026
297b626
fix: stale timezone after team switch
drankou Jul 9, 2026
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
34 changes: 34 additions & 0 deletions src/app/api/timezone/state/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { cookies } from 'next/headers'
import { z } from 'zod'
import { COOKIE_KEYS, COOKIE_OPTIONS } from '@/configs/cookies'
import { getAuthContext } from '@/core/server/auth'
import { TimezoneSchema } from '@/features/dashboard/timezone/schema'

const TimezoneStateSchema = z.object({
timezone: TimezoneSchema,
})

export const POST = async (request: Request) => {
try {
const authContext = await getAuthContext()
if (!authContext) {
return Response.json({ error: 'Unauthenticated' }, { status: 401 })
}

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>
)
}
15 changes: 8 additions & 7 deletions src/app/dashboard/[teamSlug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,20 @@ export default async function DashboardLayout({
.fetchQuery(teamsQueryOptions)
.catch(() => null)
const team = teams?.find((candidate) => candidate.slug === teamSlug)
const dashboardTeam = team
? {
id: team.id,
name: team.name,
slug: teamSlug,
}
: undefined

const featureFlagContext = {
user: {
id: authContext.user.id,
email: authContext.user.email ?? undefined,
},
team: team
? {
id: team.id,
name: team.name,
slug: teamSlug,
}
: undefined,
team: dashboardTeam,
}
const [evaluatedFeatureFlags] = await Promise.all([
featureFlags.evaluateAll(featureFlagContext),
Expand Down
19 changes: 19 additions & 0 deletions src/app/dashboard/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { cookies } from 'next/headers'
import { COOKIE_KEYS } from '@/configs/cookies'
import { TimezoneProvider } from '@/features/dashboard/timezone/context'
import { parseTimezone } from '@/features/dashboard/timezone/utils'

export default async function DashboardRootLayout({
children,
}: {
children: React.ReactNode
}) {
const cookieStore = await cookies()
const timezone = parseTimezone(
cookieStore.get(COOKIE_KEYS.DASHBOARD_TIMEZONE)?.value
)

return (
<TimezoneProvider initialTimezone={timezone}>{children}</TimezoneProvider>
)
}
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

export const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 // 1 year
Expand Down Expand Up @@ -39,4 +41,7 @@ export const COOKIE_OPTIONS = {
[COOKIE_KEYS.SANDBOX_INSPECT_ROOT_PATH]: {
...BASE_COOKIE_OPTIONS,
},
[COOKIE_KEYS.DASHBOARD_TIMEZONE]: {
...BASE_COOKIE_OPTIONS,
},
} as const
194 changes: 194 additions & 0 deletions src/features/dashboard/account/timezone-settings.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
'use client'

import { useEffect, useMemo, useState } from 'react'
import { type Timezone, useTimezone } from '@/features/dashboard/timezone'
import {
formatTimezoneLabel,
getBrowserTimezone,
getTimezones,
} from '@/features/dashboard/timezone/utils'
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
}

const TIMEZONE_SAVE_SETTLE_DELAY_MS = 300

export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => {
const { timezone, setTimezone } = useTimezone()
const { toast } = useToast()
const [open, setOpen] = useState(false)
const [isSaving, setIsSaving] = useState(false)

const [browserTimezone, setBrowserTimezone] = useState<Timezone | null>(null)
const [availableTimezones, setAvailableTimezones] = useState<Timezone[]>([
timezone,
])

useEffect(() => {
const detectedBrowserTimezone = getBrowserTimezone()
setBrowserTimezone(detectedBrowserTimezone)
setAvailableTimezones(
Array.from(
new Set([timezone, detectedBrowserTimezone, ...getTimezones()])
)
)
}, [timezone])

const timezoneOptions = useMemo(
() =>
availableTimezones.map((option) => ({
value: option,
label: formatTimezoneLabel(option),
})),
[availableTimezones]
)
const timezoneLabel = useMemo(() => formatTimezoneLabel(timezone), [timezone])
const browserTimezoneLabel = useMemo(
() =>
browserTimezone ? formatTimezoneLabel(browserTimezone) : 'Detecting...',
[browserTimezone]
)
const isBrowserTimezoneSelected = timezone === browserTimezone
const showUseBrowserTimezoneButton =
Boolean(browserTimezone) && !isBrowserTimezoneSelected

const handleTimezoneSelect = async (nextTimezone: Timezone) => {
if (isSaving) return

if (nextTimezone === timezone) {
setOpen(false)
return
}

setIsSaving(true)
setOpen(false)
const didSave = await setTimezone(nextTimezone)

await new Promise((resolve) =>
window.setTimeout(resolve, TIMEZONE_SAVE_SETTLE_DELAY_MS)
)
setIsSaving(false)

if (!didSave) {
toast(defaultErrorToast('Failed to update timezone preference.'))
return
}

toast(defaultSuccessToast('Timezone updated.'))
}

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={(nextOpen) => {
if (!isSaving) setOpen(nextOpen)
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="secondary"
disabled={isSaving}
className="w-full max-w-[24rem] justify-between font-mono"
>
{timezoneLabel}
</Button>
</PopoverTrigger>
<PopoverContent
side="top"
avoidCollisions={false}
className="w-[24rem] max-w-[calc(100vw-2rem)] p-0"
>
<Command className="flex-col-reverse **:[[cmdk-input-wrapper]]:border-t **:[[cmdk-input-wrapper]]:border-b-0">
<CommandInput placeholder="Search timezones..." />
<CommandList>
<CommandEmpty>No timezones found.</CommandEmpty>
{timezoneOptions.map((option) => (
<CommandItem
key={option.value}
value={option.label}
disabled={isSaving}
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>
<div className="flex min-h-9 min-w-49 justify-end">
<Button
type="button"
variant="secondary"
className={cn(!showUseBrowserTimezoneButton && 'invisible')}
disabled={!showUseBrowserTimezoneButton || isSaving}
aria-hidden={!showUseBrowserTimezoneButton}
tabIndex={
showUseBrowserTimezoneButton && !isSaving ? undefined : -1
}
onClick={() => {
if (browserTimezone) void handleTimezoneSelect(browserTimezone)
}}
>
Use browser timezone
</Button>
</div>
</CardFooter>
</Card>
)
}
14 changes: 4 additions & 10 deletions src/features/dashboard/billing/invoices.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
'use client'

import Link from 'next/link'
import { useTimezone } from '@/features/dashboard/timezone'
import { cn } from '@/lib/utils'
import { formatCurrency } from '@/lib/utils/formatting'
import { formatCurrency, formatDate } from '@/lib/utils/formatting'
import { Badge } from '@/ui/primitives/badge'
import { Button } from '@/ui/primitives/button'
import { ArrowDownIcon, ExternalLinkIcon } from '@/ui/primitives/icons'
Expand Down Expand Up @@ -30,14 +31,6 @@ function colStyle(width: number) {
return { width, minWidth: width, maxWidth: width }
}

function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('en-US', {
day: '2-digit',
month: 'short',
year: 'numeric',
})
}

interface InvoicesEmptyProps {
error?: string
}
Expand All @@ -59,6 +52,7 @@ function InvoicesEmpty({ error }: InvoicesEmptyProps) {
}

export default function BillingInvoicesTable() {
const { timezone } = useTimezone()
const { invoices, isLoading, error } = useInvoices()

const hasData = invoices && invoices.length > 0
Expand Down Expand Up @@ -102,7 +96,7 @@ export default function BillingInvoicesTable() {
invoices.map((invoice) => (
<TableRow key={invoice.url} className="h-11">
<TableCell className="py-0">
{formatDate(invoice.date_created)}
{formatDate(invoice.date_created, { timezone }) ?? '—'}
</TableCell>
<TableCell className="py-0">
<Badge variant={invoice.paid ? 'positive' : 'warning'}>
Expand Down
Loading
Loading