diff --git a/src/app/api/timezone/state/route.ts b/src/app/api/timezone/state/route.ts new file mode 100644 index 000000000..88b863121 --- /dev/null +++ b/src/app/api/timezone/state/route.ts @@ -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 }) + } +} 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 bfb85e02e..346486b82 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -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' @@ -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) @@ -65,22 +70,26 @@ export default async function DashboardLayout({ {postHogEnabled && } - -
-
- - - - - {children} - - - + + +
+
+ + + + + {children} + + + +
-
- + + ) diff --git a/src/configs/cookies.ts b/src/configs/cookies.ts index d1a610437..a18186a5e 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 export const COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365 // 1 year @@ -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 diff --git a/src/features/dashboard/account/timezone-settings.tsx b/src/features/dashboard/account/timezone-settings.tsx new file mode 100644 index 000000000..3d3e92077 --- /dev/null +++ b/src/features/dashboard/account/timezone-settings.tsx @@ -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(null) + const [availableTimezones, setAvailableTimezones] = useState([ + 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 ( + + + Timezone + + Choose how dashboard time ranges, charts, and timestamp labels should + be displayed. + + + + + { + if (!isSaving) setOpen(nextOpen) + }} + > + + + + + + + + No timezones found. + {timezoneOptions.map((option) => ( + void handleTimezoneSelect(option.value)} + className="justify-between" + > + {option.label} + {option.value === timezone ? ( + + Selected + + ) : null} + + ))} + + + + + + + +

+ Browser timezone:{' '} + {browserTimezoneLabel} +

+
+ +
+
+
+ ) +} diff --git a/src/features/dashboard/billing/invoices.tsx b/src/features/dashboard/billing/invoices.tsx index 1dc034fb6..7c3b9ae5c 100644 --- a/src/features/dashboard/billing/invoices.tsx +++ b/src/features/dashboard/billing/invoices.tsx @@ -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' @@ -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 } @@ -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 @@ -102,7 +96,7 @@ export default function BillingInvoicesTable() { invoices.map((invoice) => ( - {formatDate(invoice.date_created)} + {formatDate(invoice.date_created, { timezone }) ?? '—'} diff --git a/src/features/dashboard/build/header-cells.tsx b/src/features/dashboard/build/header-cells.tsx index 423013c16..3c8f75777 100644 --- a/src/features/dashboard/build/header-cells.tsx +++ b/src/features/dashboard/build/header-cells.tsx @@ -1,9 +1,10 @@ import { useRouter } from 'next/navigation' import { useEffect, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' +import { useTimezone } from '@/features/dashboard/timezone' import { useRouteParams } from '@/lib/hooks/use-route-params' import { - formatCompactDate, + formatDate, formatDurationCompact, formatTimeAgoCompact, } from '@/lib/utils/formatting' @@ -51,6 +52,7 @@ export function RanFor({ finishedAt: number | null isBuilding: boolean }) { + const { timezone } = useTimezone() const [now, setNow] = useState(() => Date.now()) useEffect(() => { @@ -77,7 +79,10 @@ export function RanFor({ } const iso = new Date(finishedAt).toISOString() - const formattedTimestamp = formatCompactDate(finishedAt) + const formattedTimestamp = formatDate(finishedAt, { + timezone, + format: 'compact-timestamp', + }) return ( { return @@ -22,6 +20,7 @@ export const Timestamp = ({ timestampUnix, millisAfterStart, }: TimestampProps) => { + const { timezone } = useTimezone() const date = new Date(timestampUnix) return ( @@ -31,8 +30,9 @@ export const Timestamp = ({ > {formatDurationCompact(millisAfterStart, true)}{' '} - {format(date, 'hh:mm:ss.SS a', { - locale: enUS, + {formatDate(timestampUnix, { + timezone, + format: 'time-with-centiseconds', })} diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 79b4cfaa7..d73bf68a7 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -10,6 +10,7 @@ import { PROTECTED_URLS } from '@/configs/urls' import type { TeamMember } from '@/core/modules/teams/models' import { getTeamDisplayName } from '@/core/modules/teams/utils' import { UserAvatar } from '@/features/dashboard/shared' +import { useTimezone } from '@/features/dashboard/timezone' import { defaultErrorToast, defaultSuccessToast, @@ -66,6 +67,7 @@ export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { const trpc = useTRPC() const queryClient = useQueryClient() const { team, user } = useDashboard() + const { timezone } = useTimezone() const [removeDialogOpen, setRemoveDialogOpen] = useState(false) const removeMemberMutation = useMutation( @@ -109,7 +111,7 @@ export const MemberTableRow = ({ member, addedByMember }: TableRowProps) => { const isCurrentUser = member.info.id === user?.id const showRemove = shouldShowRemoveMemberAction(member, user?.id) const dateStr = member.info.createdAt - ? formatDate(new Date(member.info.createdAt), 'MMM d, yyyy') + ? formatDate(member.info.createdAt, { timezone }) : null const addedBySystem = wasAddedBySystem(member, addedByMember) diff --git a/src/features/dashboard/sandbox/events/table.tsx b/src/features/dashboard/sandbox/events/table.tsx index 9bacac74a..27f743e50 100644 --- a/src/features/dashboard/sandbox/events/table.tsx +++ b/src/features/dashboard/sandbox/events/table.tsx @@ -13,7 +13,8 @@ import { VirtualizedTableRow, } from '@/features/dashboard/common/virtualized-table-ui' import { IdBadge } from '@/features/dashboard/shared' -import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDateParts } from '@/lib/utils/formatting' import CopyButtonInline from '@/ui/copy-button-inline' import { JsonPopover } from '@/ui/json-popover' import { Button } from '@/ui/primitives/button' @@ -161,8 +162,10 @@ const SandboxEventRow = ({ virtualRow, virtualizer, }: SandboxEventRowProps) => { - const formattedTimestamp = formatLocalLogStyleTimestamp(event.timestamp, { - includeCentiseconds: true, + const { timezone } = useTimezone() + const formattedTimestamp = formatDateParts(event.timestamp, { + timezone, + format: 'date-time-with-centiseconds', }) const eventDataValue = useMemo( () => JSON.stringify(event.eventData ?? {}), diff --git a/src/features/dashboard/sandbox/header/ended-at.tsx b/src/features/dashboard/sandbox/header/ended-at.tsx index a807172fe..0adc98db3 100644 --- a/src/features/dashboard/sandbox/header/ended-at.tsx +++ b/src/features/dashboard/sandbox/header/ended-at.tsx @@ -1,50 +1,34 @@ 'use client' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDate, getRelativeDay } from '@/lib/utils/formatting' import CopyButton from '@/ui/copy-button' import { useSandboxContext } from '../context' export default function EndedAt() { const { sandboxInfo, sandboxLifecycle } = useSandboxContext() + const { timezone } = useTimezone() if ( !sandboxInfo || (sandboxInfo.state !== 'killed' && sandboxInfo.state !== 'paused') - ) { + ) return null - } const endedAt = sandboxInfo.state === 'killed' ? sandboxLifecycle?.endedAt : sandboxLifecycle?.pausedAt - if (!endedAt) { - return

N/A

- } - - const date = new Date(endedAt) - const now = new Date() - const isToday = date.toDateString() === now.toDateString() - const isYesterday = - date.toDateString() === - new Date(now.setDate(now.getDate() - 1)).toDateString() - - const prefix = isToday - ? 'Today' - : isYesterday - ? 'Yesterday' - : date.toLocaleDateString() + if (!endedAt) return

N/A

- const timeStr = date.toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - }) + const relativeDay = getRelativeDay(endedAt, timezone) + const timeStr = formatDate(endedAt, { timezone, format: 'time' }) return (

- {prefix}, {timeStr} + {relativeDay}, {timeStr}

diff --git a/src/features/dashboard/sandbox/header/started-at.tsx b/src/features/dashboard/sandbox/header/started-at.tsx index 2e6614ba1..0497a7481 100644 --- a/src/features/dashboard/sandbox/header/started-at.tsx +++ b/src/features/dashboard/sandbox/header/started-at.tsx @@ -1,39 +1,24 @@ 'use client' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDate, getRelativeDay } from '@/lib/utils/formatting' import CopyButton from '@/ui/copy-button' import { useSandboxContext } from '../context' export default function StartedAt() { const { sandboxLifecycle } = useSandboxContext() + const { timezone } = useTimezone() const startedAt = sandboxLifecycle?.createdAt - if (!startedAt) { - return null - } + if (!startedAt) return null - const date = new Date(startedAt) - const now = new Date() - const isToday = date.toDateString() === now.toDateString() - const isYesterday = - date.toDateString() === - new Date(now.setDate(now.getDate() - 1)).toDateString() - - const prefix = isToday - ? 'Today' - : isYesterday - ? 'Yesterday' - : date.toLocaleDateString() - - const timeStr = date.toLocaleTimeString([], { - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - }) + const relativeDay = getRelativeDay(startedAt, timezone) + const timeStr = formatDate(startedAt, { timezone, format: 'time' }) return (

- {prefix}, {timeStr} + {relativeDay}, {timeStr}

diff --git a/src/features/dashboard/sandbox/inspect/stopped-banner.tsx b/src/features/dashboard/sandbox/inspect/stopped-banner.tsx index c9b1c9683..45edfb0f6 100644 --- a/src/features/dashboard/sandbox/inspect/stopped-banner.tsx +++ b/src/features/dashboard/sandbox/inspect/stopped-banner.tsx @@ -2,7 +2,9 @@ import { AnimatePresence, motion } from 'motion/react' import { useMemo } from 'react' +import { useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/utils/formatting' import { CardDescription, CardHeader, @@ -19,6 +21,7 @@ interface StoppedBannerProps { export function StoppedBanner({ rootNodeCount }: StoppedBannerProps) { const { isRunning, sandboxInfo } = useSandboxContext() + const { timezone } = useTimezone() const lastUpdated = useLastUpdated() const watcherError = useWatcherError() @@ -64,7 +67,11 @@ export function StoppedBanner({ rootNodeCount }: StoppedBannerProps) { : 'Filesystem data is stale and is kept locally on your device.'} {' '} - Last updated: {lastUpdated?.toLocaleTimeString()} + Last updated:{' '} + {lastUpdated + ? (formatDate(lastUpdated, { timezone, format: 'time' }) ?? + '—') + : '—'} diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx index 15c138a55..ba7830c1f 100644 --- a/src/features/dashboard/sandbox/logs/logs-cells.tsx +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -1,19 +1,11 @@ +'use client' + import type { SandboxLogModel } from '@/core/modules/sandboxes/models' import { LogLevelBadge } from '@/features/dashboard/common/log-cells' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDateParts } from '@/lib/utils/formatting' import CopyButtonInline from '@/ui/copy-button-inline' -const LOCAL_DATE_FORMATTER = new Intl.DateTimeFormat(undefined, { - month: 'short', - day: '2-digit', -}) - -const LOCAL_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, -}) - export const LogLevel = ({ level }: { level: SandboxLogModel['level'] }) => { return } @@ -23,21 +15,28 @@ interface TimestampProps { } export const Timestamp = ({ timestampUnix }: TimestampProps) => { - const date = new Date(timestampUnix) - - const centiseconds = Math.floor((date.getMilliseconds() / 10) % 100) - .toString() - .padStart(2, '0') - const localDatePart = LOCAL_DATE_FORMATTER.format(date) - const localTimePart = LOCAL_TIME_FORMATTER.format(date) + const { timezone } = useTimezone() + const formattedTimestamp = formatDateParts(timestampUnix, { + timezone, + format: 'date-time-with-centiseconds', + }) + + const copyValue = formattedTimestamp?.iso ?? '' + const content = formattedTimestamp ? ( + <> + {formattedTimestamp.datePart}{' '} + {formattedTimestamp.timePart}.{formattedTimestamp.subsecondPart} + + ) : ( + '--' + ) return ( - {localDatePart} {localTimePart}. - {centiseconds} + {content} ) } diff --git a/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx b/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx index 51bc27a39..a79d9d972 100644 --- a/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx @@ -1,4 +1,6 @@ +import type { Timezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/utils/formatting' import { AddIcon, BlockIcon, @@ -22,8 +24,6 @@ const EVENT_LINE_BASE_OPACITY = 0.35 const MARKER_BG_OPACITY = 0.1 const MARKER_BORDER_OPACITY = 0.12 -import { formatHoverTimestamp } from '../utils/formatters' - const SANDBOX_LIFECYCLE_EVENT_ICON_MAP: Record = { [SANDBOX_LIFECYCLE_EVENT_CREATED]: AddIcon, [SANDBOX_LIFECYCLE_EVENT_PAUSED]: PausedIcon, @@ -34,9 +34,11 @@ const SANDBOX_LIFECYCLE_EVENT_ICON_MAP: Record = { function LifecycleEventOverlayGroup({ overlays, showEventLabels, + timezone, }: { overlays: LifecycleEventOverlay[] showEventLabels: boolean + timezone: Timezone }) { 'use no memo' @@ -114,7 +116,10 @@ function LifecycleEventOverlayGroup({ {eventOverlay.label} - {formatHoverTimestamp(eventOverlay.timestampMs)} + {formatDate(eventOverlay.timestampMs, { + timezone, + format: 'time-24h', + })}
@@ -205,6 +210,7 @@ export function ChartOverlayLayer({ showEventLabels, isMobile, axisPointerColor, + timezone, }: { lifecycleEventOverlays: LifecycleEventOverlay[] crosshairMarkers: CrosshairMarker[] @@ -212,6 +218,7 @@ export function ChartOverlayLayer({ showEventLabels: boolean isMobile: boolean axisPointerColor: string + timezone: Timezone }) { 'use no memo' @@ -229,6 +236,7 @@ export function ChartOverlayLayer({ {xAxisHoverBadge ? ( diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx index 2cdd149f4..ffb7a1f15 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-sandbox-metrics-chart.tsx @@ -18,6 +18,7 @@ import ReactEChartsCore from 'echarts-for-react/lib/core' import { useTheme } from 'next-themes' import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react' import { calculateStepForDuration } from '@/features/dashboard/sandboxes/monitoring/utils' +import { useTimezone } from '@/features/dashboard/timezone' import { useCssVars } from '@/lib/hooks/use-css-vars' import { cn } from '@/lib/utils' import { calculateAxisMax } from '@/lib/utils/chart' @@ -156,6 +157,7 @@ function SandboxMetricsChart({ const chartInstanceRef = useRef(null) const [chartRevision, setChartRevision] = useState(0) const { resolvedTheme } = useTheme() + const { timezone } = useTimezone() const computedYAxisMax = useMemo(() => { const values = series.flatMap((line) => @@ -497,7 +499,8 @@ function SandboxMetricsChart({ fontFamily: fontMono, fontSize: CHART_AXIS_LABEL_FONT_SIZE, hideOverlap: true, - formatter: (value: number | string) => formatXAxisLabel(value), + formatter: (value: number | string) => + formatXAxisLabel(value, timezone), }, axisPointer: { show: true, @@ -553,6 +556,7 @@ function SandboxMetricsChart({ xAxisMax, xAxisMin, yAxisFormatter, + timezone, ]) // echarts-for-react uses merge mode (notMerge: false) by default, which @@ -594,6 +598,7 @@ function SandboxMetricsChart({ computedYAxisMax, cssVars, yAxisFormatter, + timezone, }) return ( @@ -614,6 +619,7 @@ function SandboxMetricsChart({ showEventLabels={showEventLabels} isMobile={isMobile} axisPointerColor={axisPointerColor} + timezone={timezone} /> ) diff --git a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx index fedbb7e3d..7fbac4215 100644 --- a/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/monitoring-time-range-controls.tsx @@ -1,7 +1,9 @@ 'use client' import { useCallback, useMemo, useState } from 'react' +import { useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/utils/formatting' import { LiveDot } from '@/ui/live' import { Button } from '@/ui/primitives/button' import { IconButton } from '@/ui/primitives/icon-button' @@ -12,13 +14,9 @@ import { PopoverTrigger, } from '@/ui/primitives/popover' import { Separator } from '@/ui/primitives/separator' -import { TimeRangePicker, type TimeRangeValues } from '@/ui/time-range-picker' -import { parseTimeRangeValuesToTimestamps } from '@/ui/time-range-picker.logic' +import { TimeRangePicker } from '@/ui/time-range-picker' import { type TimeRangePreset, TimeRangePresets } from '@/ui/time-range-presets' -import { - SANDBOX_MONITORING_CUSTOM_END_FUTURE_MS, - SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, -} from '../utils/constants' +import { SANDBOX_MONITORING_CUSTOM_END_FUTURE_MS } from '../utils/constants' import { findPresetById, getMonitoringPresets } from '../utils/presets' import { computeLifecyclePadding, @@ -41,11 +39,6 @@ function toSafeIsoDateTime( return new Date(fallbackTimestampMs).toISOString() } -const rangeLabelFormatter = new Intl.DateTimeFormat( - undefined, - SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS -) - interface SandboxMonitoringTimeRangeControlsProps { timeframe: { start: number @@ -72,6 +65,7 @@ export default function SandboxMonitoringTimeRangeControls({ onResetZoom, className, }: SandboxMonitoringTimeRangeControlsProps) { + const { timezone } = useTimezone() const [isOpen, setIsOpen] = useState(false) const [pickerMaxDateMs, setPickerMaxDateMs] = useState(() => Date.now()) const [pickerTimeframe, setPickerTimeframe] = useState(timeframe) @@ -109,10 +103,14 @@ export default function SandboxMonitoringTimeRangeControls({ return '--' } - return `${rangeLabelFormatter.format(startDate)} - ${rangeLabelFormatter.format( - endDate - )}` - }, [timeframe.end, timeframe.start]) + const startLabel = formatDate(startDate, { + timezone, + format: 'time-24h', + }) + const endLabel = formatDate(endDate, { timezone, format: 'time-24h' }) + + return `${startLabel} - ${endLabel}` + }, [timeframe.end, timeframe.start, timezone]) const lifecyclePadding = useMemo(() => { const anchorEndMs = lifecycle.isRunning @@ -164,13 +162,8 @@ export default function SandboxMonitoringTimeRangeControls({ ) const handleApply = useCallback( - (values: TimeRangeValues) => { - const timestamps = parseTimeRangeValuesToTimestamps(values) - if (!timestamps) { - return - } - - onCustomTimeRange(timestamps.start, timestamps.end) + (start: number, end: number) => { + onCustomTimeRange(start, end) setIsOpen(false) }, [onCustomTimeRange] @@ -209,7 +202,7 @@ export default function SandboxMonitoringTimeRangeControls({ )} endDateTime={toSafeIsoDateTime(pickerTimeframe.end)} bounds={pickerBounds} - onApply={handleApply} + onApply={({ start, end }) => handleApply(start, end)} className="p-3 w-56 max-md:w-full" /> yAxisFormatter: (value: number) => string + timezone: Timezone } interface UseChartOverlaysResult { @@ -88,6 +90,7 @@ export function useChartOverlays({ computedYAxisMax, cssVars, yAxisFormatter, + timezone, }: UseChartOverlaysOptions): UseChartOverlaysResult { 'use no memo' @@ -207,9 +210,9 @@ export function useChartOverlays({ return { xPx: pixel[0], - label: formatXAxisLabel(hoveredTimestampMs, true), + label: formatXAxisLabel(hoveredTimestampMs, timezone, true), } - }, [chartRevision, hoveredTimestampMs, showXAxisLabels]) + }, [chartRevision, hoveredTimestampMs, showXAxisLabels, timezone]) const lifecycleEventOverlays = useMemo(() => { void chartRevision diff --git a/src/features/dashboard/sandbox/monitoring/utils/chart-data-utils.ts b/src/features/dashboard/sandbox/monitoring/utils/chart-data-utils.ts index e9db46c95..7b9aceb7f 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/chart-data-utils.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/chart-data-utils.ts @@ -1,3 +1,5 @@ +import type { Timezone } from '@/features/dashboard/timezone' +import { formatDate } from '@/lib/utils/formatting' import type { SandboxMetricsDataPoint } from '../types/sandbox-metrics-chart' export function toNumericValue(value: unknown): number { @@ -14,25 +16,16 @@ export function toNumericValue(value: unknown): number { export function formatXAxisLabel( value: number | string, + timezone: Timezone, includeSeconds: boolean = false ): string { const timestamp = Number(value) - if (Number.isNaN(timestamp)) { - return '' - } - - const date = new Date(timestamp) - const hours = date.getHours().toString().padStart(2, '0') - const minutes = date.getMinutes().toString().padStart(2, '0') - const base = `${hours}:${minutes}` - - if (!includeSeconds) { - return base - } - - const seconds = date.getSeconds().toString().padStart(2, '0') - - return `${base}:${seconds}` + return ( + formatDate(timestamp, { + timezone, + format: includeSeconds ? 'time-24h' : 'time-24h-no-seconds', + }) ?? '' + ) } export function findLivePoint( diff --git a/src/features/dashboard/sandbox/monitoring/utils/constants.ts b/src/features/dashboard/sandbox/monitoring/utils/constants.ts index c97cb8295..f7b5e567e 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/constants.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/constants.ts @@ -69,11 +69,3 @@ export const SANDBOX_LIFECYCLE_EVENT_CREATED = 'sandbox.lifecycle.created' export const SANDBOX_LIFECYCLE_EVENT_PAUSED = 'sandbox.lifecycle.paused' export const SANDBOX_LIFECYCLE_EVENT_RESUMED = 'sandbox.lifecycle.resumed' export const SANDBOX_LIFECYCLE_EVENT_KILLED = 'sandbox.lifecycle.killed' - -export const SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS: Intl.DateTimeFormatOptions = - { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, - } diff --git a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts index 6818b1073..87b1fda19 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts @@ -1,16 +1,4 @@ -import { - SANDBOX_MONITORING_PERCENT_MAX, - SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, -} from './constants' - -const hoverTimestampFormatter = new Intl.DateTimeFormat( - undefined, - SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS -) - -export function formatHoverTimestamp(timestampMs: number): string { - return hoverTimestampFormatter.format(new Date(timestampMs)) -} +import { SANDBOX_MONITORING_PERCENT_MAX } from './constants' export function clampPercent(value: number): number { if (!Number.isFinite(value)) { diff --git a/src/features/dashboard/sandboxes/list/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx index 8e6163f71..2e58a708f 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -6,7 +6,8 @@ import { useMemo } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import ResourceUsage from '@/features/dashboard/common/resource-usage' import { useTemplateTableStore } from '@/features/dashboard/templates/list/stores/table-store' -import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDateParts } from '@/lib/utils/formatting' import { JsonPopover } from '@/ui/json-popover' import { Button } from '@/ui/primitives/button' import { ExternalLinkIcon } from '@/ui/primitives/icons' @@ -170,11 +171,12 @@ export function MetadataCell({ export function StartedAtCell({ getValue, }: CellContext) { + const { timezone } = useTimezone() const dateValue = (getValue() as string | undefined) ?? '' const formattedTimestamp = useMemo(() => { - return formatLocalLogStyleTimestamp(dateValue) - }, [dateValue]) + return formatDateParts(dateValue, { timezone }) + }, [dateValue, timezone]) return (
diff --git a/src/features/dashboard/sandboxes/monitoring/charts/concurrent-chart/hooks.ts b/src/features/dashboard/sandboxes/monitoring/charts/concurrent-chart/hooks.ts index bbf2ecd6b..dcd39d74d 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/concurrent-chart/hooks.ts +++ b/src/features/dashboard/sandboxes/monitoring/charts/concurrent-chart/hooks.ts @@ -1,6 +1,11 @@ import { useMemo } from 'react' import type { TeamMetricsResponse } from '@/core/modules/sandboxes/models.client' -import { formatCompactDate, formatNumber } from '@/lib/utils/formatting' +import { useTimezone } from '@/features/dashboard/timezone' +import { + formatDate, + formatDateRange, + formatNumber, +} from '@/lib/utils/formatting' import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe' import { transformMetrics } from '../team-metrics-chart' import { calculateAverage } from '../team-metrics-chart/utils' @@ -30,11 +35,15 @@ export function useDisplayMetric( chartData: ReturnType, hoveredValue: HoveredValue | null ) { + const { timezone } = useTimezone() const centralValue = useMemo(() => calculateAverage(chartData), [chartData]) return useMemo(() => { if (hoveredValue?.concurrentSandboxes !== undefined) { - const formattedDate = formatCompactDate(hoveredValue.timestamp) + const formattedDate = formatDate(hoveredValue.timestamp, { + timezone, + format: 'compact-timestamp', + }) return { displayValue: formatNumber(hoveredValue.concurrentSandboxes), label: 'at', @@ -46,10 +55,11 @@ export function useDisplayMetric( label: 'average', timestamp: null, } - }, [hoveredValue, centralValue]) + }, [hoveredValue, centralValue, timezone]) } export function useTimeRangeDisplay(timeframe: Timeframe) { + const { timezone } = useTimezone() const currentRange = useMemo( () => findMatchingChartRange(timeframe.duration), [timeframe.duration] @@ -68,7 +78,10 @@ export function useTimeRangeDisplay(timeframe: Timeframe) { // show timestamps for static mode or true custom ranges if (!timeframe.isLive || currentRange === 'custom') { - return `${formatCompactDate(timeframe.start)} - ${formatCompactDate(timeframe.end)}` + return formatDateRange(timeframe.start, timeframe.end, { + timezone, + format: 'date-time-padded-hour', + }) } return null @@ -78,6 +91,7 @@ export function useTimeRangeDisplay(timeframe: Timeframe) { timeframe.start, timeframe.end, timeframe.isLive, + timezone, ]) const customRangeCopyValue = useMemo(() => { diff --git a/src/features/dashboard/sandboxes/monitoring/charts/startrate-chart/hooks.ts b/src/features/dashboard/sandboxes/monitoring/charts/startrate-chart/hooks.ts index 16e12e382..555d52fdb 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/startrate-chart/hooks.ts +++ b/src/features/dashboard/sandboxes/monitoring/charts/startrate-chart/hooks.ts @@ -1,6 +1,7 @@ import { useMemo } from 'react' import type { TeamMetricsResponse } from '@/core/modules/sandboxes/models.client' -import { formatCompactDate, formatDecimal } from '@/lib/utils/formatting' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDate, formatDecimal } from '@/lib/utils/formatting' import { transformMetrics } from '../team-metrics-chart' import { calculateAverage } from '../team-metrics-chart/utils' @@ -21,11 +22,15 @@ export function useStartRateDisplayMetric( chartData: ReturnType, hoveredValue: HoveredValue | null ) { + const { timezone } = useTimezone() const centralValue = useMemo(() => calculateAverage(chartData), [chartData]) return useMemo(() => { if (hoveredValue?.sandboxStartRate !== undefined) { - const formattedDate = formatCompactDate(hoveredValue.timestamp) + const formattedDate = formatDate(hoveredValue.timestamp, { + timezone, + format: 'compact-timestamp', + }) return { displayValue: formatDecimal(hoveredValue.sandboxStartRate, 3), label: 'at', @@ -37,5 +42,5 @@ export function useStartRateDisplayMetric( label: 'average', timestamp: null, } - }, [hoveredValue, centralValue]) + }, [hoveredValue, centralValue, timezone]) } diff --git a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx index d0ac1cbd5..80dcacace 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx +++ b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/index.tsx @@ -19,6 +19,7 @@ import { CanvasRenderer } from 'echarts/renderers' import ReactEChartsCore from 'echarts-for-react/lib/core' import { useTheme } from 'next-themes' import { memo, useCallback, useMemo, useRef } from 'react' +import { useTimezone } from '@/features/dashboard/timezone' import { useCssVars } from '@/lib/hooks/use-css-vars' import { calculateAxisMax } from '@/lib/utils/chart' import { CHART_CONFIGS, LIVE_PADDING_MULTIPLIER } from './constants' @@ -28,6 +29,7 @@ import { createLimitLine, createLiveIndicators, createSplitLineInterval, + createTimeAxisLabelFormatter, createYAxisLabelFormatter, hasLiveData, transformMetrics, @@ -63,6 +65,7 @@ function TeamMetricsChart({ const chartRef = useRef(null) const chartInstanceRef = useRef(null) const { resolvedTheme } = useTheme() + const { timezone } = useTimezone() // use refs for callbacks to avoid re-creating chart options const onTooltipValueChangeRef = useRef(onTooltipValueChange) @@ -173,6 +176,12 @@ function TeamMetricsChart({ } }, []) + const xAxisLabelFormatter = useMemo( + () => + createTimeAxisLabelFormatter(timezone, timeframe.end - timeframe.start), + [timeframe.end, timeframe.start, timezone] + ) + // build complete echarts option once const option = useMemo(() => { // calculate y-axis max based on data only @@ -305,14 +314,7 @@ function TeamMetricsChart({ fontSize: 12, hideOverlap: true, rotate: 0, - formatter: { - year: '{yyyy}', - month: '{MMM} {d}', - day: '{MMM} {d}', - hour: '{HH}:{mm}', - minute: '{HH}:{mm}', - second: '{HH}:{mm}:{ss}', - }, + formatter: xAxisLabelFormatter, }, axisPointer: { show: true, @@ -378,6 +380,7 @@ function TeamMetricsChart({ errorHighlight, errorBg, bg1, + xAxisLabelFormatter, ]) return ( diff --git a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils.ts b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils.ts index 8a1d9e6ab..b039f2df6 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils.ts +++ b/src/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils.ts @@ -1,7 +1,32 @@ import type { ClientTeamMetric } from '@/core/modules/sandboxes/models.client' -import { formatAxisNumber } from '@/lib/utils/formatting' +import type { Timezone } from '@/features/dashboard/timezone' +import { + type DateFormat, + formatAxisNumber, + formatDate, +} from '@/lib/utils/formatting' import type { TeamMetricDataPoint } from './types' +const resolveTimeAxisLabelFormat = (rangeMs: number): DateFormat => { + if (rangeMs > 365 * 24 * 60 * 60 * 1000) return 'year' + if (rangeMs > 2 * 24 * 60 * 60 * 1000) return 'month-day' + + return 'time-24h-no-seconds' +} + +/** + * Picks an axis-label shape from the chart range and formats timestamps in the selected timezone. + * e.g. 1h range -> '09:00', 7d range -> 'Jun 8', 2y range -> '2026' + */ +export const createTimeAxisLabelFormatter = ( + timezone: Timezone, + rangeMs: number +): ((value: number) => string) => { + const format = resolveTimeAxisLabelFormat(rangeMs) + + return (value: number) => formatDate(value, { timezone, format }) ?? '' +} + /** * Transform metrics array to chart data points * Single-pass transformation with zero copying diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/hooks.ts b/src/features/dashboard/sandboxes/monitoring/time-picker/hooks.ts index cc0c559aa..fd4aaaf72 100644 --- a/src/features/dashboard/sandboxes/monitoring/time-picker/hooks.ts +++ b/src/features/dashboard/sandboxes/monitoring/time-picker/hooks.ts @@ -3,6 +3,7 @@ */ import { useEffect, useRef, useState } from 'react' +import type { Timezone } from '@/features/dashboard/timezone' import type { TimeframeState } from '@/lib/utils/timeframe' import { calculatePanelPosition, @@ -55,8 +56,8 @@ export function useTimeOptionSelection(value: TimeframeState) { /** * Hook to manage datetime state */ -export function useDateTimeState(value: TimeframeState) { - const formatted = formatTimeframeValues(value) +export function useDateTimeState(value: TimeframeState, timezone: Timezone) { + const formatted = formatTimeframeValues(value, timezone) const [startDateTime, setStartDateTime] = useState(formatted.startDateTime) const [endDateTime, setEndDateTime] = useState(formatted.endDateTime) @@ -64,11 +65,11 @@ export function useDateTimeState(value: TimeframeState) { // sync with external value changes useEffect(() => { - const formatted = formatTimeframeValues(value) + const formatted = formatTimeframeValues(value, timezone) setStartDateTime(formatted.startDateTime) setEndDateTime(formatted.endDateTime) setEndEnabled(value.mode === 'static') - }, [value]) + }, [value, timezone]) return { startDateTime, diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/index.tsx b/src/features/dashboard/sandboxes/monitoring/time-picker/index.tsx index c95960564..6e4ebcd73 100644 --- a/src/features/dashboard/sandboxes/monitoring/time-picker/index.tsx +++ b/src/features/dashboard/sandboxes/monitoring/time-picker/index.tsx @@ -2,8 +2,8 @@ import { AnimatePresence, motion } from 'motion/react' import { memo, type ReactNode, useCallback, useEffect, useRef } from 'react' +import { useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' -import { tryParseDatetime } from '@/lib/utils/formatting' import type { TimeframeState } from '@/lib/utils/timeframe' import { cardVariants } from '@/ui/primitives/card' import { @@ -15,7 +15,7 @@ import { } from '@/ui/primitives/dropdown-menu' import { ChevronRightIcon } from '@/ui/primitives/icons' import { RadioGroup, RadioGroupItem } from '@/ui/primitives/radio-group' - +import { parsePickerDateTime } from '@/ui/time-range-picker.logic' import { MAX_DAYS_AGO, TIME_OPTIONS } from './constants' import { useDateTimeState, @@ -39,6 +39,7 @@ export const TimePicker = memo(function TimePicker({ disabled = false, children, }: TimePickerProps) { + const { timezone } = useTimezone() const { timeOptionsValue, setTimeOptionsValue } = useTimeOptionSelection(value) @@ -49,7 +50,7 @@ export const TimePicker = memo(function TimePicker({ setEndDateTime, endEnabled, setEndEnabled, - } = useDateTimeState(value) + } = useDateTimeState(value, timezone) const { open, @@ -118,13 +119,26 @@ export const TimePicker = memo(function TimePicker({ const handleCustomSubmit = useCallback( (values: CustomTimeFormValues) => { const startDateTimeStr = `${values.startDate} ${values.startTime}` - const startDate = tryParseDatetime(startDateTimeStr) + const startDate = parsePickerDateTime( + values.startDate, + values.startTime, + '00:00:00', + timezone + ) const endDateTimeStr = values.endEnabled && values.endDate && values.endTime ? `${values.endDate} ${values.endTime}` : null - const endDate = endDateTimeStr ? tryParseDatetime(endDateTimeStr) : null + const endDate = + values.endEnabled && values.endDate && values.endTime + ? parsePickerDateTime( + values.endDate, + values.endTime, + '23:59:59', + timezone + ) + : null if (!startDate) return @@ -140,7 +154,7 @@ export const TimePicker = memo(function TimePicker({ }) setTimeOptionsValue('custom') } else { - const now = new Date().getTime() + const now = Date.now() const range = now - startDate.getTime() // ensure range is within acceptable bounds for live mode @@ -173,6 +187,7 @@ export const TimePicker = memo(function TimePicker({ setEndDateTime, setEndEnabled, setTimeOptionsValue, + timezone, ] ) diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/time-panel.tsx b/src/features/dashboard/sandboxes/monitoring/time-picker/time-panel.tsx index 8add8c0d7..76acf1568 100644 --- a/src/features/dashboard/sandboxes/monitoring/time-picker/time-panel.tsx +++ b/src/features/dashboard/sandboxes/monitoring/time-picker/time-panel.tsx @@ -16,10 +16,8 @@ import { } from 'react' import { type UseFormReturn, useForm } from 'react-hook-form' -import { - parseDateTimeComponents, - tryParseDatetime, -} from '@/lib/utils/formatting' +import { useTimezone } from '@/features/dashboard/timezone' +import { parseDateTimeComponents } from '@/lib/utils/formatting' import { Button } from '@/ui/primitives/button' import { Checkbox } from '@/ui/primitives/checkbox' import { @@ -31,9 +29,13 @@ import { FormMessage, } from '@/ui/primitives/form' import { TimeInput } from '@/ui/time-input' +import { parsePickerDateTime } from '@/ui/time-range-picker.logic' import { MAX_DAYS_AGO } from './constants' -import { type CustomTimeFormValues, customTimeFormSchema } from './validation' +import { + type CustomTimeFormValues, + createCustomTimeFormSchema, +} from './validation' export interface TimePanelRef { form: UseFormReturn @@ -56,8 +58,13 @@ export const TimePanel = forwardRef( ) { 'use no memo' + const { timezone } = useTimezone() const startParts = parseDateTimeComponents(startDateTime) const endParts = parseDateTimeComponents(endDateTime) + const customTimeFormSchema = useMemo( + () => createCustomTimeFormSchema(timezone), + [timezone] + ) const form = useForm({ resolver: zodResolver(customTimeFormSchema), @@ -102,12 +109,20 @@ export const TimePanel = forwardRef( const currentFormStart = form.getValues('startDate') const currentFormStartTime = currentFormStart - ? tryParseDatetime( - `${currentFormStart} ${form.getValues('startTime')}` + ? parsePickerDateTime( + currentFormStart, + form.getValues('startTime'), + '00:00:00', + timezone )?.getTime() : undefined const propStartTime = startDateTime - ? tryParseDatetime(startDateTime)?.getTime() + ? parsePickerDateTime( + startParts.date, + startParts.time, + '00:00:00', + timezone + )?.getTime() : undefined // detect meaningful external changes (>1s difference) @@ -141,6 +156,9 @@ export const TimePanel = forwardRef( form, form.formState.isDirty, isFocused, + startParts.date, + startParts.time, + timezone, ]) useEffect(() => { @@ -177,7 +195,8 @@ export const TimePanel = forwardRef(
-
setIsFocused(true)} onBlur={() => setIsFocused(false)} > @@ -194,7 +213,7 @@ export const TimePanel = forwardRef( } disabled={false} /> -
+
@@ -221,7 +240,8 @@ export const TimePanel = forwardRef(
-
setIsFocused(true)} onBlur={() => setIsFocused(false)} > @@ -243,7 +263,7 @@ export const TimePanel = forwardRef( disabled={!field.value} isLive={!field.value} /> -
+
diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts index dab7098c8..17773cd9c 100644 --- a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts +++ b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts @@ -1,5 +1,6 @@ import { calculateStepForDuration } from '@/features/dashboard/sandboxes/monitoring/utils' -import { formatDatetimeInput, tryParseDatetime } from '@/lib/utils/formatting' +import type { Timezone } from '@/features/dashboard/timezone' +import { formatDateTimeInput } from '@/lib/utils/formatting' import type { TimeframeState } from '@/lib/utils/timeframe' import { CUSTOM_PANEL_HEIGHT, @@ -7,6 +8,14 @@ import { TIME_OPTIONS, } from './constants' +const formatZonedDateTimeString = ( + timestamp: number, + timezone: Timezone +): string => { + const parts = formatDateTimeInput(timestamp, timezone) + return `${parts.date} ${parts.time}` +} + /** * Find a matching time option for a given duration based on the step size */ @@ -40,7 +49,10 @@ export function getDurationFromTimeframe( * Convert TimeframeState to formatted datetime strings * Handles both live and static modes with fallback to last hour */ -export function formatTimeframeValues(value: TimeframeState) { +export function formatTimeframeValues( + value: TimeframeState, + timezone: Timezone +) { const now = new Date() let startDateTime: string @@ -49,17 +61,16 @@ export function formatTimeframeValues(value: TimeframeState) { if (value.mode === 'live' && value.range) { const startTime = value.start || now.getTime() - value.range const endTime = value.end || now.getTime() - - startDateTime = formatDatetimeInput(new Date(startTime)) - endDateTime = formatDatetimeInput(new Date(endTime)) + startDateTime = formatZonedDateTimeString(startTime, timezone) + endDateTime = formatZonedDateTimeString(endTime, timezone) } else if (value.mode === 'static' && value.start && value.end) { - startDateTime = formatDatetimeInput(new Date(value.start)) - endDateTime = formatDatetimeInput(new Date(value.end)) + startDateTime = formatZonedDateTimeString(value.start, timezone) + endDateTime = formatZonedDateTimeString(value.end, timezone) } else { // fallback to last hour when no valid timeframe const hourAgo = now.getTime() - 60 * 60 * 1000 - startDateTime = formatDatetimeInput(new Date(hourAgo)) - endDateTime = formatDatetimeInput(now) + startDateTime = formatZonedDateTimeString(hourAgo, timezone) + endDateTime = formatZonedDateTimeString(now.getTime(), timezone) } return { @@ -96,8 +107,3 @@ export function calculatePanelPosition( return 'right' } } - -export function parseDateTimeStrings(dateStr: string, timeStr: string) { - if (!dateStr || !timeStr) return null - return tryParseDatetime(`${dateStr} ${timeStr}`) -} diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/validation.ts b/src/features/dashboard/sandboxes/monitoring/time-picker/validation.ts index 89f904448..bd6115ba8 100644 --- a/src/features/dashboard/sandboxes/monitoring/time-picker/validation.ts +++ b/src/features/dashboard/sandboxes/monitoring/time-picker/validation.ts @@ -3,117 +3,134 @@ */ import { z } from 'zod' -import { combineDateTimeStrings } from '@/lib/utils/formatting' +import type { Timezone } from '@/features/dashboard/timezone' +import { parsePickerDateTime } from '@/ui/time-range-picker.logic' import { CLOCK_SKEW_TOLERANCE, MAX_DAYS_AGO, MIN_RANGE_MS } from './constants' -export const customTimeFormSchema = z - .object({ - startDate: z.string(), - startTime: z.string(), - endDate: z.string().optional(), - endTime: z.string().optional(), - endEnabled: z.boolean(), - }) - .superRefine((data, ctx) => { - // start date and time are required - if (!data.startDate || !data.startTime) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Start date and time are required', - path: ['startDate'], - }) - return - } - - const startDateTime = combineDateTimeStrings(data.startDate, data.startTime) - - if (!startDateTime) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Invalid start date/time', - path: ['startDate'], - }) - return - } - - const now = Date.now() - const startTimestamp = startDateTime.getTime() - - // validate start date is not more than 31 days ago - if (startTimestamp < now - MAX_DAYS_AGO) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Start date cannot be more than 31 days ago', - path: ['startDate'], - }) - return - } - - // validate start date is not in the future (with tolerance for clock skew) - if (startTimestamp > now + CLOCK_SKEW_TOLERANCE) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Start date cannot be in the future', - path: ['startDate'], - }) - return - } - - // if end is enabled, validate end time - if (data.endEnabled && data.endDate && data.endTime) { - const endDateTime = combineDateTimeStrings(data.endDate, data.endTime) - - if (!endDateTime) { +const createCustomTimeFormSchema = (timezone: Timezone) => + z + .object({ + startDate: z.string(), + startTime: z.string(), + endDate: z.string().optional(), + endTime: z.string().optional(), + endEnabled: z.boolean(), + }) + .superRefine((data, ctx) => { + // start date and time are required + if (!data.startDate || !data.startTime) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Invalid end date/time', - path: ['endDate'], + message: 'Start date and time are required', + path: ['startDate'], }) return } - const endTimestamp = endDateTime.getTime() + const startDateTime = parsePickerDateTime( + data.startDate, + data.startTime, + '00:00:00', + timezone + ) - // Ensure end is after start - if (endTimestamp <= startTimestamp) { + if (!startDateTime) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'End time must be after start time', - path: ['endDate'], + message: 'Invalid start date/time', + path: ['startDate'], }) return } - // ensure minimum range - if (endTimestamp - startTimestamp < MIN_RANGE_MS) { + const now = Date.now() + const startTimestamp = startDateTime.getTime() + + // validate start date is not more than 31 days ago + if (startTimestamp < now - MAX_DAYS_AGO) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'Time range must be at least 1.5 minutes', - path: ['endDate'], + message: 'Start date cannot be more than 31 days ago', + path: ['startDate'], }) return } - // ensure end is not in the future (with tolerance) - if (endTimestamp > now + CLOCK_SKEW_TOLERANCE) { + // validate start date is not in the future (with tolerance for clock skew) + if (startTimestamp > now + CLOCK_SKEW_TOLERANCE) { ctx.addIssue({ code: z.ZodIssueCode.custom, - message: 'End date cannot be in the future', - path: ['endDate'], + message: 'Start date cannot be in the future', + path: ['startDate'], }) return } - // ensure range doesn't exceed maximum - if (endTimestamp - startTimestamp > MAX_DAYS_AGO) { - ctx.addIssue({ - code: z.ZodIssueCode.custom, - message: 'Date range cannot exceed 31 days', - path: ['endDate'], - }) - return + // if end is enabled, validate end time + if (data.endEnabled && data.endDate && data.endTime) { + const endDateTime = parsePickerDateTime( + data.endDate, + data.endTime, + '23:59:59', + timezone + ) + + if (!endDateTime) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Invalid end date/time', + path: ['endDate'], + }) + return + } + + const endTimestamp = endDateTime.getTime() + + // Ensure end is after start + if (endTimestamp <= startTimestamp) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'End time must be after start time', + path: ['endDate'], + }) + return + } + + // ensure minimum range + if (endTimestamp - startTimestamp < MIN_RANGE_MS) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Time range must be at least 1.5 minutes', + path: ['endDate'], + }) + return + } + + // ensure end is not in the future (with tolerance) + if (endTimestamp > now + CLOCK_SKEW_TOLERANCE) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'End date cannot be in the future', + path: ['endDate'], + }) + return + } + + // ensure range doesn't exceed maximum + if (endTimestamp - startTimestamp > MAX_DAYS_AGO) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'Date range cannot exceed 31 days', + path: ['endDate'], + }) + return + } } - } - }) + }) + +type CustomTimeFormValues = z.infer< + ReturnType +> -export type CustomTimeFormValues = z.infer +export { createCustomTimeFormSchema } +export type { CustomTimeFormValues } diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx index c5a4b9d56..0706e479e 100644 --- a/src/features/dashboard/settings/general/team-info.tsx +++ b/src/features/dashboard/settings/general/team-info.tsx @@ -1,6 +1,7 @@ 'use client' import { useDashboard } from '@/features/dashboard/context' +import { useTimezone } from '@/features/dashboard/timezone' import { formatDate } from '@/lib/utils/formatting' import CopyButtonInline from '@/ui/copy-button-inline' @@ -22,7 +23,8 @@ const InfoRow = ({ label, value }: { label: string; value: string }) => ( export const TeamInfo = () => { const { team } = useDashboard() - const createdAt = formatDate(new Date(team.createdAt), 'MMM d, yyyy') ?? '--' + const { timezone } = useTimezone() + const createdAt = formatDate(team.createdAt, { timezone }) ?? '--' return (
diff --git a/src/features/dashboard/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx index 5a32f32f5..f21be4e2f 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -4,9 +4,10 @@ import { usePostHog } from 'posthog-js/react' import { CLI_GENERATED_KEY_NAME } from '@/configs/api' import type { TeamAPIKey } from '@/core/modules/keys/models' import { IdBadge, UserAvatar } from '@/features/dashboard/shared' +import { useTimezone } from '@/features/dashboard/timezone' import { defaultSuccessToast, useToast } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' -import { formatDate, formatUTCTimestamp } from '@/lib/utils/formatting' +import { formatDate } from '@/lib/utils/formatting' import { E2BSquareBadge } from '@/ui/brand' import { Button } from '@/ui/primitives/button' import { KeyIcon, RemoveIcon } from '@/ui/primitives/icons' @@ -28,9 +29,10 @@ interface ApiKeysTableRowProps { export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => { const posthog = usePostHog() const { toast } = useToast() + const { timezone } = useTimezone() const addedDate = apiKey.createdAt - ? (formatDate(new Date(apiKey.createdAt), 'MMM d, yyyy') ?? '—') + ? (formatDate(apiKey.createdAt, { timezone }) ?? '—') : '—' const maskedKey = formatMaskedApiKey(apiKey) @@ -78,7 +80,10 @@ export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => { {lastUsedLabel} - {formatUTCTimestamp(new Date(lastUsedAt))} + {formatDate(lastUsedAt, { + timezone, + format: 'exact-timestamp', + })} ) : ( diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx index 8779ce4c8..a704d0198 100644 --- a/src/features/dashboard/settings/webhooks/table-row.tsx +++ b/src/features/dashboard/settings/webhooks/table-row.tsx @@ -2,9 +2,11 @@ import { Fragment, useState } from 'react' import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' +import { useTimezone } from '@/features/dashboard/timezone' import { useClipboard } from '@/lib/hooks/use-clipboard' import { defaultSuccessToast, toast } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/utils/formatting' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' import { @@ -187,13 +189,10 @@ const WebhookRowActions = ({ webhook }: WebhookRowActionsProps) => { export const WebhookTableRow = ({ webhook }: WebhookRowProps) => { const { team } = useDashboard() + const { timezone } = useTimezone() const createdAt = webhook.createdAt - ? new Date(webhook.createdAt).toLocaleDateString('en-US', { - month: 'short', - day: 'numeric', - year: 'numeric', - }) + ? (formatDate(webhook.createdAt, { timezone }) ?? '-') : '-' return ( diff --git a/src/features/dashboard/templates/list/table-cells.tsx b/src/features/dashboard/templates/list/table-cells.tsx index 3fdd5dbf3..8566f4729 100644 --- a/src/features/dashboard/templates/list/table-cells.tsx +++ b/src/features/dashboard/templates/list/table-cells.tsx @@ -4,6 +4,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import type { CellContext } from '@tanstack/react-table' import { useMemo, useState } from 'react' import type { DefaultTemplate, Template } from '@/core/modules/templates/models' +import { useTimezone } from '@/features/dashboard/timezone' import { useClipboard } from '@/lib/hooks/use-clipboard' import { defaultErrorToast, @@ -11,7 +12,7 @@ import { useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' -import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting' +import { formatDateParts } from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' import { AlertDialog } from '@/ui/alert-dialog' import { E2BBadge } from '@/ui/brand' @@ -312,14 +313,15 @@ export function TemplateNameCell({ export function CreatedAtCell({ getValue, }: CellContext