Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
fb2872a
feat(timezone): implement timezone management in dashboard
sarimrmalik Jun 8, 2026
94b2870
Update toast copy
sarimrmalik Jun 8, 2026
145bb10
fix(timezone): apply dashboard timezone to time range pickers
sarimrmalik Jun 10, 2026
f6e869f
feat(timezone): enhance time picker functionality with timezone support
sarimrmalik Jun 10, 2026
20a7016
feat(timezone): integrate timezone support in usage display utilities
sarimrmalik Jun 11, 2026
a16f020
Complete dashboard timezone display coverage
sarimrmalik Jun 11, 2026
b730d2e
Address timezone review feedback
sarimrmalik Jun 11, 2026
50f403f
Fix timezone settings hydration
sarimrmalik Jun 11, 2026
aaac695
Refine timezone range formatting
sarimrmalik Jun 11, 2026
c85fcd3
Ensure UTC appears in timezone options
sarimrmalik Jun 11, 2026
8c654d7
Require auth for timezone preference updates
sarimrmalik Jun 11, 2026
696312c
Apply dashboard timezone to remaining timestamps
sarimrmalik Jun 11, 2026
5ccbf56
Align picker bounds with dashboard timezone
sarimrmalik Jun 11, 2026
d1fe5a4
Merge branch 'main' into feat/timezone-preferences
sarimrmalik Jun 11, 2026
344e7b8
fix(timezone): satisfy TypeScript in zonedInstantToCalendarDate
sarimrmalik Jun 11, 2026
8c9c21d
refactor(timezone): centralize zoned date helpers and simplify picker…
sarimrmalik Jun 11, 2026
97a3ce6
refactor(timezone): remove unused formatters and dedupe monitoring da…
sarimrmalik Jun 11, 2026
ca2f305
fix(timezone): normalize zoned part inputs before formatToParts
sarimrmalik Jun 11, 2026
839600e
refactor(timezone): unify date formatting behind formatDate options API
sarimrmalik Jun 11, 2026
04b6bfb
refactor(timezone): use named date format presets
sarimrmalik Jun 11, 2026
f58122d
refactor(timezone): remove padded date preset
sarimrmalik Jun 11, 2026
64c8d24
Fold centisecond time into date presets
sarimrmalik Jun 11, 2026
f0276db
Centralize date part formatting
sarimrmalik Jun 11, 2026
fff9d36
Split relative day formatting
sarimrmalik Jun 12, 2026
90323f5
Use centralized time formatting
sarimrmalik Jun 12, 2026
b3240e2
Simplify log timestamp rendering
sarimrmalik Jun 12, 2026
723cf95
Consolidate time display presets
sarimrmalik Jun 12, 2026
f0f7468
Centralize axis time formats
sarimrmalik Jun 12, 2026
a305717
Align date range formatting
sarimrmalik Jun 12, 2026
4073844
Clarify date range presets
sarimrmalik Jun 12, 2026
6bd0a46
Localize axis label strategy
sarimrmalik Jun 12, 2026
fb77b6b
Rename axis label formatter
sarimrmalik Jun 12, 2026
a78626f
Rename date format type
sarimrmalik Jun 12, 2026
55a0423
Tighten timezone setter type
sarimrmalik Jun 12, 2026
a9ee548
Trim timezone type exports
sarimrmalik Jun 12, 2026
3866b98
Remove zoned helper prefixes
sarimrmalik Jun 12, 2026
d800cc0
Merge remote-tracking branch 'origin/main' into feat/timezone-prefere…
sarimrmalik Jun 12, 2026
5166b0d
style: apply biome formatting
sarimrmalik Jun 12, 2026
717e461
Merge remote-tracking branch 'origin/main' into feat/timezone-prefere…
sarimrmalik Jun 15, 2026
87ba970
refactor(timezone): reorganize timezone imports and utilities
sarimrmalik Jun 15, 2026
9d4ddc8
feat(timezone): add button to use browser timezone if not selected
sarimrmalik Jun 15, 2026
31aab98
Keep timezone popover stable
sarimrmalik Jun 16, 2026
5af42fe
Polish timezone save interaction
sarimrmalik Jun 16, 2026
97b6dc1
Merge main into timezone preferences
sarimrmalik Jun 16, 2026
3b82959
style: apply biome formatting
sarimrmalik Jun 16, 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>
)
}
39 changes: 24 additions & 15 deletions src/app/dashboard/[teamSlug]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { getAuthContext } from '@/core/server/auth'
import DashboardLayoutView from '@/features/dashboard/layouts/layout'
import { DashboardPostHogErrorBoundary } from '@/features/dashboard/posthog-error-boundary'
import Sidebar from '@/features/dashboard/sidebar/sidebar'
import { TimezoneProvider } from '@/features/dashboard/timezone/context'
import { parseTimezone } from '@/features/dashboard/timezone/utils'
import { OryPostHogIdentityBridge } from '@/features/ory-posthog-identity-bridge'
import { HydrateClient, prefetchAsync, trpc } from '@/trpc/server'
import { SidebarInset, SidebarProvider } from '@/ui/primitives/sidebar'
Expand Down Expand Up @@ -41,6 +43,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 @@ -65,22 +70,26 @@ export default async function DashboardLayout({
<HydrateClient>
{postHogEnabled && <OryPostHogIdentityBridge user={authContext.user} />}
<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>
<DashboardPostHogErrorBoundary>
<DashboardLayoutView params={params}>
{children}
</DashboardLayoutView>
</DashboardPostHogErrorBoundary>
</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>
<DashboardPostHogErrorBoundary>
<DashboardLayoutView params={params}>
{children}
</DashboardLayoutView>
</DashboardPostHogErrorBoundary>
</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

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