From 2ffe1dd53ba55db94e77ce9693fa5912c4aac50e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Mon, 8 Jun 2026 16:21:23 -0400 Subject: [PATCH 01/51] feat(timezone): implement timezone management in dashboard - Added a new API endpoint for setting the user's timezone. - Introduced TimezoneProvider and useTimezone hook for managing timezone state. - Created TimezoneSettings component for users to select their preferred timezone. - Updated dashboard layout to include timezone context and settings. - Enhanced cookie management to store the user's timezone preference. This feature allows users to customize how time-related data is displayed in the dashboard. --- src/app/api/timezone/state/route.ts | 28 ++++ src/app/dashboard/[teamSlug]/account/page.tsx | 3 + src/app/dashboard/[teamSlug]/layout.tsx | 41 +++-- src/configs/cookies.ts | 5 + .../dashboard/account/timezone-settings.tsx | 153 ++++++++++++++++++ src/features/dashboard/timezone/context.tsx | 103 ++++++++++++ src/features/dashboard/timezone/index.ts | 10 ++ src/features/dashboard/timezone/schema.ts | 21 +++ src/features/dashboard/timezone/utils.ts | 74 +++++++++ tests/unit/timezone-utils.test.ts | 78 +++++++++ 10 files changed, 499 insertions(+), 17 deletions(-) create mode 100644 src/app/api/timezone/state/route.ts create mode 100644 src/features/dashboard/account/timezone-settings.tsx create mode 100644 src/features/dashboard/timezone/context.tsx create mode 100644 src/features/dashboard/timezone/index.ts create mode 100644 src/features/dashboard/timezone/schema.ts create mode 100644 src/features/dashboard/timezone/utils.ts create mode 100644 tests/unit/timezone-utils.test.ts diff --git a/src/app/api/timezone/state/route.ts b/src/app/api/timezone/state/route.ts new file mode 100644 index 000000000..f36b32089 --- /dev/null +++ b/src/app/api/timezone/state/route.ts @@ -0,0 +1,28 @@ +import { cookies } from 'next/headers' +import { z } from 'zod' +import { COOKIE_KEYS, COOKIE_OPTIONS } from '@/configs/cookies' +import { TimezoneSchema } from '@/features/dashboard/timezone/schema' + +const TimezoneStateSchema = z.object({ + timezone: TimezoneSchema, +}) + +export const POST = async (request: Request) => { + try { + const result = TimezoneStateSchema.safeParse(await request.json()) + if (!result.success) { + return Response.json({ error: 'Invalid request' }, { status: 400 }) + } + + const cookieStore = await cookies() + cookieStore.set( + COOKIE_KEYS.DASHBOARD_TIMEZONE, + result.data.timezone, + COOKIE_OPTIONS[COOKIE_KEYS.DASHBOARD_TIMEZONE] + ) + + return Response.json({ timezone: result.data.timezone }) + } catch { + return Response.json({ error: 'Invalid request' }, { status: 400 }) + } +} diff --git a/src/app/dashboard/[teamSlug]/account/page.tsx b/src/app/dashboard/[teamSlug]/account/page.tsx index 9b2e0180a..3cefacfb7 100644 --- a/src/app/dashboard/[teamSlug]/account/page.tsx +++ b/src/app/dashboard/[teamSlug]/account/page.tsx @@ -2,6 +2,7 @@ import { AccessTokenSettings } from '@/features/dashboard/account/access-token-s import { EmailSettings } from '@/features/dashboard/account/email-settings' import { NameSettings } from '@/features/dashboard/account/name-settings' import { PasswordSettingsServer } from '@/features/dashboard/account/password-settings-server' +import { TimezoneSettings } from '@/features/dashboard/account/timezone-settings' export interface AccountPageSearchParams { reauth?: '1' @@ -21,6 +22,8 @@ export default async function AccountPage({ + + ) } diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 3c8f2e3bc..596fbaa44 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -13,6 +13,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 { getQueryClient, HydrateClient, @@ -46,6 +48,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) @@ -88,24 +93,26 @@ export default async function DashboardLayout({ - -
-
- - - - - {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..dc263a508 --- /dev/null +++ b/src/features/dashboard/account/timezone-settings.tsx @@ -0,0 +1,153 @@ +'use client' + +import { useMemo, useState } from 'react' +import { + formatTimezoneLabel, + getBrowserTimezone, + getTimezones, + type Timezone, + useTimezone, +} from '@/features/dashboard/timezone' +import { + defaultErrorToast, + defaultSuccessToast, + useToast, +} from '@/lib/hooks/use-toast' +import { cn } from '@/lib/utils' +import { Button } from '@/ui/primitives/button' +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from '@/ui/primitives/card' +import { + Command, + CommandEmpty, + CommandInput, + CommandItem, + CommandList, +} from '@/ui/primitives/command' +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/ui/primitives/popover' + +interface TimezoneSettingsProps { + className?: string +} + +export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { + const { timezone, setTimezone } = useTimezone() + const { toast } = useToast() + const [open, setOpen] = useState(false) + const [isSaving, setIsSaving] = useState(false) + + const browserTimezone = useMemo(() => getBrowserTimezone(), []) + const timezoneOptions = useMemo( + () => + getTimezones().map((option) => ({ + value: option, + label: formatTimezoneLabel(option), + })), + [] + ) + const timezoneLabel = useMemo(() => formatTimezoneLabel(timezone), [timezone]) + const browserTimezoneLabel = useMemo( + () => formatTimezoneLabel(browserTimezone), + [browserTimezone] + ) + const isBrowserTimezoneSelected = timezone === browserTimezone + + const handleTimezoneSelect = async (nextTimezone: Timezone) => { + if (nextTimezone === timezone) { + setOpen(false) + return + } + + setIsSaving(true) + const didSave = await setTimezone(nextTimezone) + setIsSaving(false) + + if (!didSave) { + toast(defaultErrorToast('Failed to update timezone preference.')) + return + } + + toast(defaultSuccessToast('Timezone preference updated.')) + setOpen(false) + } + + return ( + + + Timezone + + Choose how dashboard time ranges, charts, and timestamp labels should + be displayed. + + + + + + + + + + + + + No timezones found. + {timezoneOptions.map((option) => ( + void handleTimezoneSelect(option.value)} + className="justify-between" + > + {option.label} + {option.value === timezone ? ( + + Selected + + ) : null} + + ))} + + + + + + + +

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

+ {!isBrowserTimezoneSelected ? ( + + ) : null} +
+
+ ) +} diff --git a/src/features/dashboard/timezone/context.tsx b/src/features/dashboard/timezone/context.tsx new file mode 100644 index 000000000..c3e4dce76 --- /dev/null +++ b/src/features/dashboard/timezone/context.tsx @@ -0,0 +1,103 @@ +'use client' + +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useMemo, + useState, +} from 'react' +import { getBrowserTimezone, parseTimezone, type Timezone } from './utils' + +interface TimezoneContextValue { + timezone: Timezone + setTimezone: (timezone: string) => Promise +} + +interface TimezoneProviderProps { + children: ReactNode + initialTimezone: string | null +} + +const TimezoneContext = createContext(null) + +const persistTimezone = async (timezone: Timezone): Promise => { + try { + const response = await fetch('/api/timezone/state', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ timezone }), + }) + + return response.ok + } catch { + return false + } +} + +export const TimezoneProvider = ({ + children, + initialTimezone, +}: TimezoneProviderProps) => { + const parsedInitialTimezone = useMemo( + () => parseTimezone(initialTimezone), + [initialTimezone] + ) + const [timezone, setTimezoneState] = useState( + parsedInitialTimezone ?? getBrowserTimezone() + ) + + useEffect(() => { + if (parsedInitialTimezone) return + + const browserTimezone = getBrowserTimezone() + setTimezoneState(browserTimezone) + void persistTimezone(browserTimezone) + }, [parsedInitialTimezone]) + + const setTimezone = useCallback( + async (nextTimezone: string) => { + const parsedTimezone = parseTimezone(nextTimezone) + if (!parsedTimezone) return false + + const previousTimezone = timezone + setTimezoneState(parsedTimezone) + + const didPersist = await persistTimezone(parsedTimezone) + if (!didPersist) { + setTimezoneState(previousTimezone) + return false + } + + return true + }, + [timezone] + ) + + const value = useMemo( + () => ({ + timezone, + setTimezone, + }), + [setTimezone, timezone] + ) + + return ( + + {children} + + ) +} + +export const useTimezone = () => { + const context = useContext(TimezoneContext) + if (!context) { + throw new Error('useTimezone must be used within TimezoneProvider') + } + + return context +} diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts new file mode 100644 index 000000000..62e32926f --- /dev/null +++ b/src/features/dashboard/timezone/index.ts @@ -0,0 +1,10 @@ +export { TimezoneProvider, useTimezone } from './context' +export type { Timezone } from './schema' +export { TimezoneSchema } from './schema' +export { + formatTimezoneLabel, + getBrowserTimezone, + getTimezones, + isValidTimezone, + parseTimezone, +} from './utils' diff --git a/src/features/dashboard/timezone/schema.ts b/src/features/dashboard/timezone/schema.ts new file mode 100644 index 000000000..3b4e68bcf --- /dev/null +++ b/src/features/dashboard/timezone/schema.ts @@ -0,0 +1,21 @@ +import { z } from 'zod' + +const TimezoneSchema = z + .string() + .min(1) + .refine( + (timezone) => { + try { + new Intl.DateTimeFormat('en-US', { timeZone: timezone }) + return true + } catch { + return false + } + }, + { message: 'Invalid timezone' } + ) + .brand<'Timezone'>() + +type Timezone = z.infer + +export { TimezoneSchema, type Timezone } diff --git a/src/features/dashboard/timezone/utils.ts b/src/features/dashboard/timezone/utils.ts new file mode 100644 index 000000000..a3f3d2af5 --- /dev/null +++ b/src/features/dashboard/timezone/utils.ts @@ -0,0 +1,74 @@ +import { type Timezone, TimezoneSchema } from './schema' + +// Returns true when an IANA timezone is valid; e.g. "America/New_York" -> true. +const isValidTimezone = (timezone: string): timezone is Timezone => + TimezoneSchema.safeParse(timezone).success + +// Parses a timezone preference safely; e.g. "Europe/Berlin" -> "Europe/Berlin". +const parseTimezone = ( + timezone: string | null | undefined +): Timezone | null => { + if (!timezone) return null + + const result = TimezoneSchema.safeParse(timezone) + if (!result.success) return null + + return result.data +} + +// Returns the browser timezone with a safe fallback; e.g. browser in New York -> "America/New_York". +const getBrowserTimezone = (): Timezone => { + const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone + const parsedTimezone = parseTimezone(timezone) + if (parsedTimezone) return parsedTimezone + + const utcTimezone = TimezoneSchema.safeParse('UTC') + if (utcTimezone.success) return utcTimezone.data + + throw new Error('Unable to resolve browser timezone') +} + +// Returns supported IANA timezone options; e.g. first option can be "Africa/Abidjan". +const getTimezones = (): Timezone[] => { + const browserTimezone = getBrowserTimezone() + + if (typeof Intl.supportedValuesOf === 'function') { + const timezones = Intl.supportedValuesOf('timeZone') + if (timezones.length > 0) { + return Array.from(new Set([browserTimezone, ...timezones])) + .filter(isValidTimezone) + .sort() + } + } + + return [browserTimezone] +} + +// Formats a timezone for display; e.g. "America/New_York" -> "America/New York". +const formatTimezoneDisplayName = (timezone: Timezone): string => + timezone.replaceAll('_', ' ') + +// Formats a timezone label with its current short name; e.g. "America/New_York" -> "America/New York (EST)". +const formatTimezoneLabel = (timezone: Timezone): string => { + const formatter = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'short', + }) + + const timezoneName = formatter + .formatToParts(new Date()) + .find((part) => part.type === 'timeZoneName')?.value + + const displayName = formatTimezoneDisplayName(timezone) + if (!timezoneName) return displayName + + return `${displayName} (${timezoneName})` +} + +export { + formatTimezoneLabel, + getBrowserTimezone, + getTimezones, + isValidTimezone, + parseTimezone, +} diff --git a/tests/unit/timezone-utils.test.ts b/tests/unit/timezone-utils.test.ts new file mode 100644 index 000000000..8711ff6eb --- /dev/null +++ b/tests/unit/timezone-utils.test.ts @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + formatTimezoneLabel, + getBrowserTimezone, + getTimezones, + isValidTimezone, + parseTimezone, +} from '@/features/dashboard/timezone/utils' + +describe('timezone utils', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-01-15T12:00:00Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + describe('isValidTimezone', () => { + it('accepts valid IANA timezones and UTC', () => { + expect(isValidTimezone('America/New_York')).toBe(true) + expect(isValidTimezone('Europe/Berlin')).toBe(true) + expect(isValidTimezone('UTC')).toBe(true) + }) + + it('rejects invalid timezone values', () => { + expect(isValidTimezone('not-a-timezone')).toBe(false) + expect(isValidTimezone('')).toBe(false) + }) + }) + + describe('parseTimezone', () => { + it('returns the timezone when it is valid', () => { + expect(parseTimezone('America/New_York')).toBe('America/New_York') + }) + + it('returns null for missing or invalid timezone values', () => { + expect(parseTimezone(null)).toBeNull() + expect(parseTimezone(undefined)).toBeNull() + expect(parseTimezone('not-a-timezone')).toBeNull() + }) + }) + + describe('getTimezones', () => { + it('returns valid timezone options', () => { + const options = getTimezones() + + expect(options.length).toBeGreaterThan(0) + expect(options.every(isValidTimezone)).toBe(true) + }) + + it('includes the browser timezone', () => { + const options = getTimezones() + + expect(options).toContain(getBrowserTimezone()) + }) + }) + + describe('formatTimezoneLabel', () => { + it('includes the timezone and its short display name', () => { + const timezone = parseTimezone('America/New_York') + if (!timezone) throw new Error('Expected valid timezone') + + const label = formatTimezoneLabel(timezone) + + expect(label).toContain('America/New York') + expect(label).toContain('EST') + }) + + it('replaces underscores with spaces for display', () => { + const timezone = parseTimezone('Africa/Addis_Ababa') + if (!timezone) throw new Error('Expected valid timezone') + + expect(formatTimezoneLabel(timezone)).toContain('Africa/Addis Ababa') + }) + }) +}) From 10ed1792e5469c4f3af3d9bd97a1a5d50e596d5a Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Mon, 8 Jun 2026 16:30:47 -0400 Subject: [PATCH 02/51] Update toast copy --- src/features/dashboard/account/timezone-settings.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/features/dashboard/account/timezone-settings.tsx b/src/features/dashboard/account/timezone-settings.tsx index dc263a508..ac35f62c6 100644 --- a/src/features/dashboard/account/timezone-settings.tsx +++ b/src/features/dashboard/account/timezone-settings.tsx @@ -77,7 +77,7 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { return } - toast(defaultSuccessToast('Timezone preference updated.')) + toast(defaultSuccessToast('Timezone updated.')) setOpen(false) } From 2398887ddcd1d8fc152db1c5c546e37688a2dd36 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 10 Jun 2026 15:32:59 -0400 Subject: [PATCH 03/51] fix(timezone): apply dashboard timezone to time range pickers --- .../monitoring-time-range-controls.tsx | 27 +-- src/features/dashboard/timezone/context.tsx | 3 +- src/features/dashboard/timezone/date-time.ts | 105 +++++++++ src/features/dashboard/timezone/index.ts | 11 + src/features/dashboard/usage/constants.ts | 217 +++++++++++++----- .../usage/usage-time-range-controls.tsx | 79 ++++--- src/ui/time-input.tsx | 34 +-- src/ui/time-range-picker.logic.ts | 80 ++++--- src/ui/time-range-picker.tsx | 29 ++- tests/unit/time-range-picker-logic.test.ts | 76 ++++-- tests/unit/timezone-date-time.test.ts | 108 +++++++++ tests/unit/usage-time-range-presets.test.ts | 62 +++++ 12 files changed, 646 insertions(+), 185 deletions(-) create mode 100644 src/features/dashboard/timezone/date-time.ts create mode 100644 tests/unit/timezone-date-time.test.ts create mode 100644 tests/unit/usage-time-range-presets.test.ts 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..5ce2c84cb 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,6 +1,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' +import { useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' import { LiveDot } from '@/ui/live' import { Button } from '@/ui/primitives/button' @@ -12,8 +13,7 @@ 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, @@ -41,11 +41,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 +67,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) @@ -103,6 +99,10 @@ export default function SandboxMonitoringTimeRangeControls({ }, [activePresetId, presets]) const rangeLabel = useMemo(() => { + const rangeLabelFormatter = new Intl.DateTimeFormat(undefined, { + ...SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, + timeZone: timezone, + }) const startDate = new Date(timeframe.start) const endDate = new Date(timeframe.end) if (!isValidDate(startDate) || !isValidDate(endDate)) { @@ -112,7 +112,7 @@ export default function SandboxMonitoringTimeRangeControls({ return `${rangeLabelFormatter.format(startDate)} - ${rangeLabelFormatter.format( endDate )}` - }, [timeframe.end, timeframe.start]) + }, [timeframe.end, timeframe.start, timezone]) const lifecyclePadding = useMemo(() => { const anchorEndMs = lifecycle.isRunning @@ -164,13 +164,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 +204,7 @@ export default function SandboxMonitoringTimeRangeControls({ )} endDateTime={toSafeIsoDateTime(pickerTimeframe.end)} bounds={pickerBounds} - onApply={handleApply} + onApplyTimestamps={handleApply} className="p-3 w-56 max-md:w-full" /> String(value).padStart(2, '0') + +// Formats a timestamp for picker inputs; e.g. 2026-06-08T13:05:09Z in America/New_York -> { date: "2026/06/08", time: "09:05:09" }. +const formatZonedDateTimeInput = ( + value: string | number | Date, + timezone: Timezone +): { date: string; time: string } => ({ + date: formatInTimeZone(value, timezone, 'yyyy/MM/dd'), + time: formatInTimeZone(value, timezone, 'HH:mm:ss'), +}) + +// Converts timezone wall-clock parts to UTC; e.g. 2026-06-08 09:00:00 in America/New_York -> 2026-06-08T13:00:00.000Z. +const zonedDateTimePartsToUtcDate = ( + parts: ZonedDateTimeParts, + timezone: Timezone +): Date => { + const wallClockValue = `${parts.year}-${pad(parts.month)}-${pad( + parts.day + )}T${pad(parts.hours)}:${pad(parts.minutes)}:${pad(parts.seconds)}` + + return fromZonedTime(wallClockValue, timezone) +} + +// Converts timezone wall-clock parts to a UTC timestamp; e.g. 2026-06-08 09:00:00 in America/New_York -> 1780923600000. +const zonedDateTimePartsToUtcTimestamp = ( + parts: ZonedDateTimeParts, + timezone: Timezone +): number => zonedDateTimePartsToUtcDate(parts, timezone).getTime() + +// Formats the short timezone abbreviation; e.g. 2026-06-08T13:00:00Z in America/New_York -> "EDT". +const formatTimezoneAbbreviation = ( + value: string | number | Date, + timezone: Timezone +): string => { + const abbreviation = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + timeZoneName: 'short', + }) + .formatToParts(new Date(value)) + .find((part) => part.type === 'timeZoneName')?.value + + return abbreviation ?? timezone +} + +// Formats a compact timezone-aware date range; e.g. UTC start/end in America/New_York -> "Jun 8, 2026 - Jun 9, 2026, EDT". +const formatZonedDateRange = ( + start: string | number | Date, + end: string | number | Date, + timezone: Timezone, + { + includeTime = false, + includeTimezone = true, + }: ZonedDateRangeFormatOptions = {} +): string => { + const baseOptions: Intl.DateTimeFormatOptions = { + timeZone: timezone, + year: 'numeric', + month: 'short', + day: 'numeric', + } + + if (includeTime) { + baseOptions.hour = '2-digit' + baseOptions.minute = '2-digit' + baseOptions.second = '2-digit' + } + + const endOptions: Intl.DateTimeFormatOptions = { + ...baseOptions, + timeZoneName: includeTimezone ? 'short' : undefined, + } + + const startFormatter = new Intl.DateTimeFormat('en-US', baseOptions) + const endFormatter = new Intl.DateTimeFormat('en-US', endOptions) + + return `${startFormatter.format(new Date(start))} - ${endFormatter.format( + new Date(end) + )}` +} + +export { + formatTimezoneAbbreviation, + formatZonedDateRange, + formatZonedDateTimeInput, + zonedDateTimePartsToUtcDate, + zonedDateTimePartsToUtcTimestamp, +} +export type { ZonedDateRangeFormatOptions, ZonedDateTimeParts } diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index 62e32926f..649a098b6 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -1,4 +1,15 @@ export { TimezoneProvider, useTimezone } from './context' +export type { + ZonedDateRangeFormatOptions, + ZonedDateTimeParts, +} from './date-time' +export { + formatTimezoneAbbreviation, + formatZonedDateRange, + formatZonedDateTimeInput, + zonedDateTimePartsToUtcDate, + zonedDateTimePartsToUtcTimestamp, +} from './date-time' export type { Timezone } from './schema' export { TimezoneSchema } from './schema' export { diff --git a/src/features/dashboard/usage/constants.ts b/src/features/dashboard/usage/constants.ts index 91c7441d6..f05e068c0 100644 --- a/src/features/dashboard/usage/constants.ts +++ b/src/features/dashboard/usage/constants.ts @@ -1,3 +1,7 @@ +import { + type Timezone, + zonedDateTimePartsToUtcTimestamp, +} from '@/features/dashboard/timezone' import { formatAxisNumber } from '@/lib/utils/formatting' import type { TimeRangePreset } from '@/ui/time-range-presets' import type { @@ -17,18 +21,116 @@ export const INITIAL_TIMEFRAME_FALLBACK_RANGE_MS = 30 * 24 * 60 * 60 * 1000 export const HOURLY_SAMPLING_THRESHOLD_DAYS = 3 export const WEEKLY_SAMPLING_THRESHOLD_DAYS = 60 -export const TIME_RANGE_PRESETS: TimeRangePreset[] = [ +interface CalendarDateParts { + year: number + month: number + day: number +} + +const getZonedDateParts = ( + value: Date, + timezone: Timezone +): CalendarDateParts => { + const [yearPart, monthPart, dayPart] = new Intl.DateTimeFormat('en-CA', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }) + .format(value) + .split('-') + + const year = Number.parseInt(yearPart ?? '', 10) + const month = Number.parseInt(monthPart ?? '', 10) + const day = Number.parseInt(dayPart ?? '', 10) + + if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) { + throw new Error('Unable to format usage preset date') + } + + return { year, month, day } +} + +// Shifts a calendar date without applying local timezone rules; e.g. 2026-06-10 + -89 -> 2026-03-13. +const shiftCalendarDays = ( + parts: CalendarDateParts, + days: number +): CalendarDateParts => { + const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)) + date.setUTCDate(date.getUTCDate() + days) + + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + } +} + +// Shifts to the first day of another calendar month; e.g. 2026-06-10 + -1 -> 2026-05-01. +const shiftToMonthStart = ( + parts: CalendarDateParts, + monthOffset: number +): CalendarDateParts => { + const date = new Date(Date.UTC(parts.year, parts.month - 1 + monthOffset, 1)) + + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: 1, + } +} + +// Returns the last calendar date in a month; e.g. 2026-02 -> 2026-02-28. +const getMonthEnd = (year: number, month: number): CalendarDateParts => { + const date = new Date(Date.UTC(year, month, 0)) + + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + } +} + +// Converts selected-zone day boundaries to UTC milliseconds; e.g. 2026-06-10 in America/New_York -> 04:00Z to 03:59:59.999Z. +const getZonedDayBoundaryTimestamps = ( + startParts: CalendarDateParts, + endParts: CalendarDateParts, + timezone: Timezone +): { start: number; end: number } => ({ + start: zonedDateTimePartsToUtcTimestamp( + { + ...startParts, + hours: 0, + minutes: 0, + seconds: 0, + }, + timezone + ), + end: + zonedDateTimePartsToUtcTimestamp( + { + ...endParts, + hours: 23, + minutes: 59, + seconds: 59, + }, + timezone + ) + 999, +}) + +// Returns usage presets bound to selected-zone calendar days; e.g. Last 7 days in America/New_York starts at New York midnight. +const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ { id: 'last-7-days', label: 'Last 7 days', shortcut: '7D', getValue: () => { - const end = new Date() - end.setHours(23, 59, 59, 999) - const start = new Date(end) - start.setDate(start.getDate() - 6) - start.setHours(0, 0, 0, 0) - return { start: start.getTime(), end: end.getTime() } + const today = getZonedDateParts(new Date(), timezone) + return getZonedDayBoundaryTimestamps( + shiftCalendarDays(today, -6), + today, + timezone + ) }, }, { @@ -36,12 +138,12 @@ export const TIME_RANGE_PRESETS: TimeRangePreset[] = [ label: 'Last 14 days', shortcut: '14D', getValue: () => { - const end = new Date() - end.setHours(23, 59, 59, 999) - const start = new Date(end) - start.setDate(start.getDate() - 13) - start.setHours(0, 0, 0, 0) - return { start: start.getTime(), end: end.getTime() } + const today = getZonedDateParts(new Date(), timezone) + return getZonedDayBoundaryTimestamps( + shiftCalendarDays(today, -13), + today, + timezone + ) }, }, { @@ -49,12 +151,12 @@ export const TIME_RANGE_PRESETS: TimeRangePreset[] = [ label: 'Last 30 days', shortcut: '30D', getValue: () => { - const end = new Date() - end.setHours(23, 59, 59, 999) - const start = new Date(end) - start.setDate(start.getDate() - 29) - start.setHours(0, 0, 0, 0) - return { start: start.getTime(), end: end.getTime() } + const today = getZonedDateParts(new Date(), timezone) + return getZonedDayBoundaryTimestamps( + shiftCalendarDays(today, -29), + today, + timezone + ) }, }, { @@ -62,76 +164,63 @@ export const TIME_RANGE_PRESETS: TimeRangePreset[] = [ label: 'Last 90 days', shortcut: '90D', getValue: () => { - const end = new Date() - end.setHours(23, 59, 59, 999) - const start = new Date(end) - start.setDate(start.getDate() - 89) - start.setHours(0, 0, 0, 0) - return { start: start.getTime(), end: end.getTime() } + const today = getZonedDateParts(new Date(), timezone) + return getZonedDayBoundaryTimestamps( + shiftCalendarDays(today, -89), + today, + timezone + ) }, }, { id: 'this-month', label: 'This month', getValue: () => { - const now = new Date() - const start = new Date(now.getFullYear(), now.getMonth(), 1, 0, 0, 0, 0) - const end = new Date( - now.getFullYear(), - now.getMonth() + 1, - 0, - 23, - 59, - 59, - 999 + const today = getZonedDateParts(new Date(), timezone) + const start = { year: today.year, month: today.month, day: 1 } + return getZonedDayBoundaryTimestamps( + start, + getMonthEnd(today.year, today.month), + timezone ) - return { start: start.getTime(), end: end.getTime() } }, }, { id: 'last-month', label: 'Last month', getValue: () => { - const now = new Date() - const start = new Date( - now.getFullYear(), - now.getMonth() - 1, - 1, - 0, - 0, - 0, - 0 - ) - const end = new Date( - now.getFullYear(), - now.getMonth(), - 0, - 23, - 59, - 59, - 999 + const today = getZonedDateParts(new Date(), timezone) + const start = shiftToMonthStart(today, -1) + return getZonedDayBoundaryTimestamps( + start, + getMonthEnd(start.year, start.month), + timezone ) - return { start: start.getTime(), end: end.getTime() } }, }, { id: 'this-year', label: 'This year', getValue: () => { - const now = new Date() - const start = new Date(now.getFullYear(), 0, 1, 0, 0, 0, 0) - const end = new Date(now.getFullYear(), 11, 31, 23, 59, 59, 999) - return { start: start.getTime(), end: end.getTime() } + const today = getZonedDateParts(new Date(), timezone) + return getZonedDayBoundaryTimestamps( + { year: today.year, month: 1, day: 1 }, + { year: today.year, month: 12, day: 31 }, + timezone + ) }, }, { id: 'last-year', label: 'Last year', getValue: () => { - const now = new Date() - const start = new Date(now.getFullYear() - 1, 0, 1, 0, 0, 0, 0) - const end = new Date(now.getFullYear() - 1, 11, 31, 23, 59, 59, 999) - return { start: start.getTime(), end: end.getTime() } + const today = getZonedDateParts(new Date(), timezone) + const year = today.year - 1 + return getZonedDayBoundaryTimestamps( + { year, month: 1, day: 1 }, + { year, month: 12, day: 31 }, + timezone + ) }, }, ] @@ -173,3 +262,5 @@ export const COMPUTE_CHART_CONFIGS: Record< yAxisFormatter: formatAxisNumber, }, } + +export { getUsageTimeRangePresets } diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index a1e11be61..3ef453e67 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -1,6 +1,10 @@ 'use client' -import { useCallback, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { + formatZonedDateRange, + useTimezone, +} from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' import { findMatchingPreset } from '@/lib/utils/time-range' import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe' @@ -13,10 +17,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 { TIME_RANGE_PRESETS } from './constants' +import { getUsageTimeRangePresets } from './constants' import { determineSamplingMode, normalizeToEndOfSamplingPeriod, @@ -41,34 +44,57 @@ export function UsageTimeRangeControls({ onTimeRangeChange, className, }: UsageTimeRangeControlsProps) { + const { timezone } = useTimezone() const [isTimePickerOpen, setIsTimePickerOpen] = useState(false) + const lastMatchedPresetIdRef = useRef(undefined) + const previousTimezoneRef = useRef(timezone) + + const timeRangePresets = useMemo( + () => getUsageTimeRangePresets(timezone), + [timezone] + ) const selectedPresetId = useMemo( () => findMatchingPreset( - TIME_RANGE_PRESETS, + timeRangePresets, timeframe.start, timeframe.end, 1000 * 60 * 60 * 24 // 1 day in tolerance ), - [timeframe.start, timeframe.end] + [timeRangePresets, timeframe.start, timeframe.end] ) + useEffect(() => { + if (previousTimezoneRef.current === timezone) return + + previousTimezoneRef.current = timezone + const presetId = selectedPresetId ?? lastMatchedPresetIdRef.current + if (!presetId) return + + const preset = timeRangePresets.find((option) => option.id === presetId) + if (!preset) return + + const { start, end } = preset.getValue() + if (start === timeframe.start && end === timeframe.end) return + + onTimeRangeChange(start, end) + }, [ + onTimeRangeChange, + selectedPresetId, + timeframe.end, + timeframe.start, + timeRangePresets, + timezone, + ]) + + useEffect(() => { + lastMatchedPresetIdRef.current = selectedPresetId + }, [selectedPresetId]) + const rangeLabel = useMemo(() => { - const opt: Intl.DateTimeFormatOptions = { - year: 'numeric', - month: 'short', - day: 'numeric', - } - - const firstFormatter = new Intl.DateTimeFormat('en-US', opt) - - const lastFormatter = new Intl.DateTimeFormat('en-US', { - ...opt, - timeZoneName: 'short', - }) - return `${firstFormatter.format(timeframe.start)} - ${lastFormatter.format(timeframe.end)}` - }, [timeframe.start, timeframe.end]) + return formatZonedDateRange(timeframe.start, timeframe.end, timezone) + }, [timeframe.start, timeframe.end, timezone]) const rangeCopyValue = useMemo( () => formatTimeframeAsISO8601Interval(timeframe.start, timeframe.end), @@ -110,13 +136,8 @@ export function UsageTimeRangeControls({ }, [timeframe, quarterOfRangeDuration, onTimeRangeChange]) const handleTimeRangeApply = useCallback( - (values: TimeRangeValues) => { - const timestamps = parseTimeRangeValuesToTimestamps(values) - if (!timestamps) { - return - } - - onTimeRangeChange(timestamps.start, timestamps.end) + (start: number, end: number) => { + onTimeRangeChange(start, end) setIsTimePickerOpen(false) }, [onTimeRangeChange] @@ -166,7 +187,7 @@ export function UsageTimeRangeControls({ startDateTime={new Date(timeframe.start).toISOString()} endDateTime={new Date(timeframe.end).toISOString()} bounds={USAGE_TIME_RANGE_BOUNDS} - onApply={handleTimeRangeApply} + onApplyTimestamps={handleTimeRangeApply} className="p-3 w-56 max-md:w-full" /> = 0 ? '+' : '-' - - if (minutes === 0) { - return `GMT${sign}${hours}` - } - return `GMT${sign}${hours}:${String(minutes).padStart(2, '0')}` -} - export interface TimeInputProps { dateValue: string timeValue: string @@ -70,6 +48,7 @@ export const TimeInput = memo(function TimeInput({ maxDate, hideTime = false, }: TimeInputProps) { + const { timezone } = useTimezone() const [dateOpen, setDateOpen] = useState(false) const [timeOpen, setTimeOpen] = useState(false) @@ -83,6 +62,7 @@ export const TimeInput = memo(function TimeInput({ const [hours, setHours] = useState(timeDate ? timeDate.getHours() : 0) const [minutes, setMinutes] = useState(timeDate ? timeDate.getMinutes() : 0) const [seconds, setSeconds] = useState(timeDate ? timeDate.getSeconds() : 0) + const timezoneIdentifier = formatTimezoneAbbreviation(Date.now(), timezone) useEffect(() => { setDisplayDate(dateValue || '') @@ -191,7 +171,7 @@ export const TimeInput = memo(function TimeInput({ />
- {getTimezoneIdentifier()} + {timezoneIdentifier} diff --git a/src/ui/time-range-picker.logic.ts b/src/ui/time-range-picker.logic.ts index 585950bf8..603158aae 100644 --- a/src/ui/time-range-picker.logic.ts +++ b/src/ui/time-range-picker.logic.ts @@ -1,4 +1,7 @@ import { z } from 'zod' +import { zonedDateTimePartsToUtcDate } from '@/features/dashboard/timezone/date-time' +import type { Timezone } from '@/features/dashboard/timezone/schema' +import { getBrowserTimezone } from '@/features/dashboard/timezone/utils' export interface TimeRangeValues { startDate: string @@ -25,9 +28,10 @@ export interface TimeRangeValidationResult { issues: TimeRangeIssue[] } -interface TimeRangeValidationOptions { +export interface TimeRangeValidationOptions { hideTime: boolean bounds?: TimeRangePickerBounds + timezone?: Timezone } function normalizeDateInput(value: string): string { @@ -167,7 +171,8 @@ function formatTimeValue( export function parsePickerDateTime( dateInput: string, timeInput: string | null | undefined, - fallbackTime: string + fallbackTime: string, + timezone: Timezone = getBrowserTimezone() ): Date | null { const parsedDate = parseDateInput(dateInput) if (!parsedDate) { @@ -181,29 +186,39 @@ export function parsePickerDateTime( return null } - return new Date( - parsedDate.getFullYear(), - parsedDate.getMonth(), - parsedDate.getDate(), - parsedTime.hours, - parsedTime.minutes, - parsedTime.seconds, - 0 + return zonedDateTimePartsToUtcDate( + { + year: parsedDate.getFullYear(), + month: parsedDate.getMonth() + 1, + day: parsedDate.getDate(), + hours: parsedTime.hours, + minutes: parsedTime.minutes, + seconds: parsedTime.seconds, + }, + timezone ) } -function formatBoundaryDateTime(date: Date, hideTime: boolean): string { - const datePart = formatDateValue(date) - if (hideTime) { - return datePart +function formatBoundaryDateTime( + date: Date, + hideTime: boolean, + timezone: Timezone +): string { + const options: Intl.DateTimeFormatOptions = { + timeZone: timezone, + year: 'numeric', + month: 'numeric', + day: 'numeric', + timeZoneName: 'short', } - const timePart = formatTimeValue( - date.getHours(), - date.getMinutes(), - date.getSeconds() - ) - return `${datePart} ${timePart}` + if (!hideTime) { + options.hour = '2-digit' + options.minute = '2-digit' + options.second = '2-digit' + } + + return new Intl.DateTimeFormat('en-US', options).format(date) } function normalizeTimeValue(time: string | null): string | null { @@ -242,17 +257,20 @@ export function normalizeTimeRangeValues( } export function parseTimeRangeValuesToTimestamps( - values: TimeRangeValues + values: TimeRangeValues, + timezone: Timezone = getBrowserTimezone() ): { start: number; end: number } | null { const startDateTime = parsePickerDateTime( values.startDate, values.startTime, - '00:00:00' + '00:00:00', + timezone ) const endDateTime = parsePickerDateTime( values.endDate, values.endTime, - '23:59:59' + '23:59:59', + timezone ) if (!startDateTime || !endDateTime) { @@ -267,19 +285,25 @@ export function parseTimeRangeValuesToTimestamps( export function validateTimeRangeValues( values: TimeRangeValues, - { bounds, hideTime }: TimeRangeValidationOptions + { + bounds, + hideTime, + timezone = getBrowserTimezone(), + }: TimeRangeValidationOptions ): TimeRangeValidationResult { const issues: TimeRangeIssue[] = [] const startDateTime = parsePickerDateTime( values.startDate, hideTime ? null : values.startTime, - '00:00:00' + '00:00:00', + timezone ) const endDateTime = parsePickerDateTime( values.endDate, hideTime ? null : values.endTime, - '23:59:59' + '23:59:59', + timezone ) if (!startDateTime) { @@ -310,14 +334,14 @@ export function validateTimeRangeValues( if (minBoundary && startDateTime.getTime() < minBoundary.getTime()) { issues.push({ field: 'startDate', - message: `Start date cannot be before ${formatBoundaryDateTime(minBoundary, hideTime)}`, + message: `Start date cannot be before ${formatBoundaryDateTime(minBoundary, hideTime, timezone)}`, }) } if (maxBoundary && endDateTime.getTime() > maxBoundary.getTime()) { issues.push({ field: 'endDate', - message: `End date cannot be after ${formatBoundaryDateTime(maxBoundary, hideTime)}`, + message: `End date cannot be after ${formatBoundaryDateTime(maxBoundary, hideTime, timezone)}`, }) } diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx index fba258d7f..7025a79a5 100644 --- a/src/ui/time-range-picker.tsx +++ b/src/ui/time-range-picker.tsx @@ -5,8 +5,11 @@ import { endOfDay, startOfDay } from 'date-fns' import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' +import { + formatZonedDateTimeInput, + useTimezone, +} from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' -import { parseDateTimeComponents } from '@/lib/utils/formatting' import { Button } from './primitives/button' import { @@ -21,6 +24,7 @@ import { TimeInput } from './time-input' import { createTimeRangeSchema, normalizeTimeRangeValues, + parseTimeRangeValuesToTimestamps, type TimeRangePickerBounds, type TimeRangeValues, } from './time-range-picker.logic' @@ -32,6 +36,7 @@ interface TimeRangePickerProps { endDateTime: string bounds?: TimeRangePickerBounds onApply?: (values: TimeRangeValues) => void + onApplyTimestamps?: (start: number, end: number) => void onChange?: (values: TimeRangeValues) => void className?: string hideTime?: boolean @@ -42,22 +47,24 @@ export function TimeRangePicker({ endDateTime, bounds, onApply, + onApplyTimestamps, onChange, className, hideTime = false, }: TimeRangePickerProps) { 'use no memo' + const { timezone } = useTimezone() const minBoundMs = bounds?.min?.getTime() const maxBoundMs = bounds?.max?.getTime() const startParts = useMemo( - () => parseDateTimeComponents(startDateTime), - [startDateTime] + () => formatZonedDateTimeInput(startDateTime, timezone), + [startDateTime, timezone] ) const endParts = useMemo( - () => parseDateTimeComponents(endDateTime), - [endDateTime] + () => formatZonedDateTimeInput(endDateTime, timezone), + [endDateTime, timezone] ) const calendarMinDate = useMemo( @@ -79,8 +86,9 @@ export function TimeRangePicker({ min: minBoundMs !== undefined ? new Date(minBoundMs) : undefined, max: maxBoundMs !== undefined ? new Date(maxBoundMs) : undefined, }, + timezone, }) - }, [hideTime, maxBoundMs, minBoundMs]) + }, [hideTime, maxBoundMs, minBoundMs, timezone]) const defaultValues = useMemo( () => ({ @@ -128,9 +136,16 @@ export function TimeRangePicker({ (values: TimeRangeValues) => { const normalizedValues = normalizeTimeRangeValues(values) onApply?.(normalizedValues) + const timestamps = parseTimeRangeValuesToTimestamps( + normalizedValues, + timezone + ) + if (timestamps) { + onApplyTimestamps?.(timestamps.start, timestamps.end) + } form.reset(normalizedValues) }, - [form, onApply] + [form, onApply, onApplyTimestamps, timezone] ) const shouldValidateOnChange = form.formState.submitCount > 0 diff --git a/tests/unit/time-range-picker-logic.test.ts b/tests/unit/time-range-picker-logic.test.ts index 73f17cb6b..047cf4233 100644 --- a/tests/unit/time-range-picker-logic.test.ts +++ b/tests/unit/time-range-picker-logic.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { parseTimezone, type Timezone } from '@/features/dashboard/timezone' import { createTimeRangeSchema, normalizeTimeRangeValues, @@ -8,6 +9,16 @@ import { validateTimeRangeValues, } from '@/ui/time-range-picker.logic' +const requireTimezone = (value: string): Timezone => { + const timezone = parseTimezone(value) + if (!timezone) throw new Error(`Expected ${value} to be a valid timezone`) + + return timezone +} + +const utc = requireTimezone('UTC') +const newYork = requireTimezone('America/New_York') + const baseValues: TimeRangeValues = { startDate: '2026/02/18', startTime: '00:00:00', @@ -18,7 +29,7 @@ const baseValues: TimeRangeValues = { describe('time-range-picker logic', () => { describe('parsePickerDateTime', () => { it('returns null when date is missing, even if time exists', () => { - const parsed = parsePickerDateTime('', '18:00:00', '23:59:59') + const parsed = parsePickerDateTime('', '18:00:00', '23:59:59', utc) expect(parsed).toBeNull() }) @@ -26,18 +37,31 @@ describe('time-range-picker logic', () => { const canonical = parsePickerDateTime( '2026/02/24', '18:17:41', - '23:59:59' + '23:59:59', + utc ) const display = parsePickerDateTime( '24 / 02 / 2026', '18 : 17 : 41', - '23:59:59' + '23:59:59', + utc ) expect(canonical).not.toBeNull() expect(display).not.toBeNull() expect(canonical?.getTime()).toBe(display?.getTime()) }) + + it('interprets wall-clock values in the selected timezone', () => { + const parsed = parsePickerDateTime( + '2026/06/08', + '09:00:00', + '00:00:00', + newYork + ) + + expect(parsed?.toISOString()).toBe('2026-06-08T13:00:00.000Z') + }) }) describe('normalizeTimeRangeValues', () => { @@ -68,8 +92,9 @@ describe('time-range-picker logic', () => { }, { hideTime: false, + timezone: utc, bounds: { - min: new Date(2023, 0, 1, 0, 0, 0), + min: new Date(Date.UTC(2023, 0, 1, 0, 0, 0)), }, } ) @@ -80,8 +105,9 @@ describe('time-range-picker logic', () => { it('validates against explicit max boundary', () => { const validation = validateTimeRangeValues(baseValues, { hideTime: false, + timezone: utc, bounds: { - max: new Date(2026, 1, 24, 18, 17, 41), + max: new Date(Date.UTC(2026, 1, 24, 18, 17, 41)), }, }) @@ -107,6 +133,7 @@ describe('time-range-picker logic', () => { }, { hideTime: false, + timezone: utc, } ) @@ -123,16 +150,36 @@ describe('time-range-picker logic', () => { describe('parseTimeRangeValuesToTimestamps', () => { it('converts values to start and end timestamps with fallback times', () => { - const timestamps = parseTimeRangeValuesToTimestamps({ - startDate: '2026/02/18', - startTime: null, - endDate: '2026/02/24', - endTime: null, - }) + const timestamps = parseTimeRangeValuesToTimestamps( + { + startDate: '2026/02/18', + startTime: null, + endDate: '2026/02/24', + endTime: null, + }, + utc + ) expect(timestamps).not.toBeNull() - expect(timestamps?.start).toBe(new Date(2026, 1, 18, 0, 0, 0).getTime()) - expect(timestamps?.end).toBe(new Date(2026, 1, 24, 23, 59, 59).getTime()) + expect(timestamps?.start).toBe(Date.UTC(2026, 1, 18, 0, 0, 0)) + expect(timestamps?.end).toBe(Date.UTC(2026, 1, 24, 23, 59, 59)) + }) + + it('converts values to UTC timestamps from the selected timezone', () => { + const timestamps = parseTimeRangeValuesToTimestamps( + { + startDate: '2026/06/08', + startTime: '09:00:00', + endDate: '2026/06/08', + endTime: '10:00:00', + }, + newYork + ) + + expect(timestamps).toEqual({ + start: Date.UTC(2026, 5, 8, 13, 0, 0), + end: Date.UTC(2026, 5, 8, 14, 0, 0), + }) }) }) @@ -140,8 +187,9 @@ describe('time-range-picker logic', () => { it('applies the same boundary validation as logic helpers', () => { const schema = createTimeRangeSchema({ hideTime: false, + timezone: utc, bounds: { - max: new Date(2026, 1, 24, 18, 17, 41), + max: new Date(Date.UTC(2026, 1, 24, 18, 17, 41)), }, }) diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts new file mode 100644 index 000000000..0d7cc3b0a --- /dev/null +++ b/tests/unit/timezone-date-time.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest' +import { + formatTimezoneAbbreviation, + formatZonedDateRange, + formatZonedDateTimeInput, + parseTimezone, + type Timezone, + zonedDateTimePartsToUtcDate, + zonedDateTimePartsToUtcTimestamp, +} from '@/features/dashboard/timezone' + +const requireTimezone = (value: string): Timezone => { + const timezone = parseTimezone(value) + if (!timezone) throw new Error(`Expected ${value} to be a valid timezone`) + + return timezone +} + +describe('timezone date-time helpers', () => { + const newYork = requireTimezone('America/New_York') + const berlin = requireTimezone('Europe/Berlin') + const utc = requireTimezone('UTC') + + describe('formatZonedDateTimeInput', () => { + it('formats a UTC timestamp into New York picker parts', () => { + const result = formatZonedDateTimeInput( + '2026-06-08T13:05:09.000Z', + newYork + ) + + expect(result).toEqual({ + date: '2026/06/08', + time: '09:05:09', + }) + }) + + it('formats a UTC timestamp into Berlin picker parts', () => { + const result = formatZonedDateTimeInput( + '2026-06-08T13:05:09.000Z', + berlin + ) + + expect(result).toEqual({ + date: '2026/06/08', + time: '15:05:09', + }) + }) + }) + + describe('zonedDateTimePartsToUtcDate', () => { + it('converts New York wall-clock parts to UTC', () => { + const result = zonedDateTimePartsToUtcDate( + { + year: 2026, + month: 6, + day: 8, + hours: 9, + minutes: 0, + seconds: 0, + }, + newYork + ) + + expect(result.toISOString()).toBe('2026-06-08T13:00:00.000Z') + }) + + it('converts UTC wall-clock parts without offset changes', () => { + const result = zonedDateTimePartsToUtcTimestamp( + { + year: 2026, + month: 6, + day: 8, + hours: 9, + minutes: 0, + seconds: 0, + }, + utc + ) + + expect(result).toBe(Date.UTC(2026, 5, 8, 9, 0, 0)) + }) + }) + + describe('formatTimezoneAbbreviation', () => { + it('formats daylight saving and standard abbreviations', () => { + expect( + formatTimezoneAbbreviation('2026-06-08T13:00:00.000Z', newYork) + ).toBe('EDT') + expect( + formatTimezoneAbbreviation('2026-01-08T14:00:00.000Z', newYork) + ).toBe('EST') + }) + }) + + describe('formatZonedDateRange', () => { + it('formats a range in the selected timezone', () => { + const result = formatZonedDateRange( + '2026-06-08T13:00:00.000Z', + '2026-06-09T13:00:00.000Z', + newYork + ) + + expect(result).toContain('Jun 8, 2026') + expect(result).toContain('Jun 9, 2026') + expect(result).toContain('EDT') + }) + }) +}) diff --git a/tests/unit/usage-time-range-presets.test.ts b/tests/unit/usage-time-range-presets.test.ts new file mode 100644 index 000000000..d75a1e05d --- /dev/null +++ b/tests/unit/usage-time-range-presets.test.ts @@ -0,0 +1,62 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { + formatZonedDateTimeInput, + parseTimezone, + type Timezone, +} from '@/features/dashboard/timezone' +import { getUsageTimeRangePresets } from '@/features/dashboard/usage/constants' + +const requireTimezone = (value: string): Timezone => { + const timezone = parseTimezone(value) + if (!timezone) throw new Error(`Expected ${value} to be a valid timezone`) + + return timezone +} + +const getPresetRange = (timezone: Timezone, presetId: string) => { + const preset = getUsageTimeRangePresets(timezone).find( + (option) => option.id === presetId + ) + if (!preset) throw new Error(`Expected ${presetId} preset to exist`) + + return preset.getValue() +} + +describe('usage time range presets', () => { + const newYork = requireTimezone('America/New_York') + const losAngeles = requireTimezone('America/Los_Angeles') + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-10T16:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('computes last 90 days from midnight to end of day in the selected timezone', () => { + const newYorkRange = getPresetRange(newYork, 'last-90-days') + const losAngelesRange = getPresetRange(losAngeles, 'last-90-days') + + expect(formatZonedDateTimeInput(newYorkRange.start, newYork)).toEqual({ + date: '2026/03/13', + time: '00:00:00', + }) + expect(formatZonedDateTimeInput(newYorkRange.end, newYork)).toEqual({ + date: '2026/06/10', + time: '23:59:59', + }) + + expect(formatZonedDateTimeInput(losAngelesRange.start, losAngeles)).toEqual( + { + date: '2026/03/13', + time: '00:00:00', + } + ) + expect(formatZonedDateTimeInput(losAngelesRange.end, losAngeles)).toEqual({ + date: '2026/06/10', + time: '23:59:59', + }) + }) +}) From 1663db2bb80c3a7c4d19d23d0d0832fb4b5c6377 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Wed, 10 Jun 2026 15:48:32 -0400 Subject: [PATCH 04/51] feat(timezone): enhance time picker functionality with timezone support - Updated `useDateTimeState` to accept a timezone parameter for accurate date-time formatting. - Integrated timezone management into the TimePicker and TimePanel components. - Refactored date-time parsing and validation to utilize timezone-aware functions. - Improved user experience by ensuring all date-time inputs respect the selected timezone. --- .../sandboxes/monitoring/time-picker/hooks.ts | 9 +- .../monitoring/time-picker/index.tsx | 27 ++- .../monitoring/time-picker/time-panel.tsx | 44 +++-- .../sandboxes/monitoring/time-picker/utils.ts | 33 ++-- .../monitoring/time-picker/validation.ts | 187 ++++++++++-------- src/features/dashboard/usage/constants.ts | 22 ++- .../unit/team-monitoring-time-picker.test.ts | 56 ++++++ 7 files changed, 252 insertions(+), 126 deletions(-) create mode 100644 tests/unit/team-monitoring-time-picker.test.ts 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..70280b833 100644 --- a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts +++ b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts @@ -1,5 +1,8 @@ import { calculateStepForDuration } from '@/features/dashboard/sandboxes/monitoring/utils' -import { formatDatetimeInput, tryParseDatetime } from '@/lib/utils/formatting' +import { + formatZonedDateTimeInput, + type Timezone, +} from '@/features/dashboard/timezone' import type { TimeframeState } from '@/lib/utils/timeframe' import { CUSTOM_PANEL_HEIGHT, @@ -40,7 +43,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 @@ -50,16 +56,22 @@ export function formatTimeframeValues(value: TimeframeState) { const startTime = value.start || now.getTime() - value.range const endTime = value.end || now.getTime() - startDateTime = formatDatetimeInput(new Date(startTime)) - endDateTime = formatDatetimeInput(new Date(endTime)) + const startParts = formatZonedDateTimeInput(startTime, timezone) + const endParts = formatZonedDateTimeInput(endTime, timezone) + startDateTime = `${startParts.date} ${startParts.time}` + endDateTime = `${endParts.date} ${endParts.time}` } else if (value.mode === 'static' && value.start && value.end) { - startDateTime = formatDatetimeInput(new Date(value.start)) - endDateTime = formatDatetimeInput(new Date(value.end)) + const startParts = formatZonedDateTimeInput(value.start, timezone) + const endParts = formatZonedDateTimeInput(value.end, timezone) + startDateTime = `${startParts.date} ${startParts.time}` + endDateTime = `${endParts.date} ${endParts.time}` } else { // fallback to last hour when no valid timeframe const hourAgo = now.getTime() - 60 * 60 * 1000 - startDateTime = formatDatetimeInput(new Date(hourAgo)) - endDateTime = formatDatetimeInput(now) + const startParts = formatZonedDateTimeInput(hourAgo, timezone) + const endParts = formatZonedDateTimeInput(now, timezone) + startDateTime = `${startParts.date} ${startParts.time}` + endDateTime = `${endParts.date} ${endParts.time}` } return { @@ -96,8 +108,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/usage/constants.ts b/src/features/dashboard/usage/constants.ts index f05e068c0..e64b7cb48 100644 --- a/src/features/dashboard/usage/constants.ts +++ b/src/features/dashboard/usage/constants.ts @@ -31,18 +31,28 @@ const getZonedDateParts = ( value: Date, timezone: Timezone ): CalendarDateParts => { - const [yearPart, monthPart, dayPart] = new Intl.DateTimeFormat('en-CA', { + const parts = new Intl.DateTimeFormat('en-US', { timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', }) - .format(value) - .split('-') + .formatToParts(value) + .reduce>((result, part) => { + if ( + part.type === 'year' || + part.type === 'month' || + part.type === 'day' + ) { + result[part.type] = part.value + } - const year = Number.parseInt(yearPart ?? '', 10) - const month = Number.parseInt(monthPart ?? '', 10) - const day = Number.parseInt(dayPart ?? '', 10) + return result + }, {}) + + const year = Number.parseInt(parts.year ?? '', 10) + const month = Number.parseInt(parts.month ?? '', 10) + const day = Number.parseInt(parts.day ?? '', 10) if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) { throw new Error('Unable to format usage preset date') diff --git a/tests/unit/team-monitoring-time-picker.test.ts b/tests/unit/team-monitoring-time-picker.test.ts new file mode 100644 index 000000000..d3c9f5652 --- /dev/null +++ b/tests/unit/team-monitoring-time-picker.test.ts @@ -0,0 +1,56 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { formatTimeframeValues } from '@/features/dashboard/sandboxes/monitoring/time-picker/utils' +import { createCustomTimeFormSchema } from '@/features/dashboard/sandboxes/monitoring/time-picker/validation' +import { parseTimezone, type Timezone } from '@/features/dashboard/timezone' + +const requireTimezone = (value: string): Timezone => { + const timezone = parseTimezone(value) + if (!timezone) throw new Error(`Expected ${value} to be a valid timezone`) + + return timezone +} + +describe('team monitoring time picker', () => { + const newYork = requireTimezone('America/New_York') + const losAngeles = requireTimezone('America/Los_Angeles') + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-10T16:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('formats static timeframe values in the selected timezone', () => { + const value = { + mode: 'static' as const, + start: Date.UTC(2026, 5, 10, 13, 0, 0), + end: Date.UTC(2026, 5, 10, 14, 0, 0), + } + + expect(formatTimeframeValues(value, newYork)).toEqual({ + startDateTime: '2026/06/10 09:00:00', + endDateTime: '2026/06/10 10:00:00', + }) + expect(formatTimeframeValues(value, losAngeles)).toEqual({ + startDateTime: '2026/06/10 06:00:00', + endDateTime: '2026/06/10 07:00:00', + }) + }) + + it('validates custom wall-clock input in the selected timezone', () => { + const schema = createCustomTimeFormSchema(newYork) + + const result = schema.safeParse({ + startDate: '2026/06/10', + startTime: '09:00:00', + endDate: '2026/06/10', + endTime: '10:00:00', + endEnabled: true, + }) + + expect(result.success).toBe(true) + }) +}) From b81f894fe9a715ab1e7541bed9ce92743eac8822 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 11 Jun 2026 14:28:05 -0400 Subject: [PATCH 05/51] feat(timezone): integrate timezone support in usage display utilities - Added functions to format dates and times based on the user's timezone. - Updated `formatAxisDate` and `formatHoveredValues` to accept a timezone parameter for accurate display. - Implemented new utility functions for handling zoned date formatting. - Created unit tests to verify timezone formatting functionality. --- src/features/dashboard/usage/display-utils.ts | 122 +++++++++++++++--- .../dashboard/usage/usage-charts-context.tsx | 51 +++++--- tests/unit/usage-display-utils.test.ts | 51 ++++++++ 3 files changed, 186 insertions(+), 38 deletions(-) create mode 100644 tests/unit/usage-display-utils.test.ts diff --git a/src/features/dashboard/usage/display-utils.ts b/src/features/dashboard/usage/display-utils.ts index 734433b5f..ea291a45a 100644 --- a/src/features/dashboard/usage/display-utils.ts +++ b/src/features/dashboard/usage/display-utils.ts @@ -1,10 +1,5 @@ -import { - formatCurrency, - formatDateRange, - formatDay, - formatHour, - formatNumber, -} from '@/lib/utils/formatting' +import type { Timezone } from '@/features/dashboard/timezone' +import { formatCurrency, formatNumber } from '@/lib/utils/formatting' import { determineSamplingMode, normalizeToEndOfSamplingPeriod, @@ -17,6 +12,93 @@ import type { Timeframe, } from './types' +const getZonedYear = (value: number | Date, timezone: Timezone): number => { + const year = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + }).format(value) + + return Number.parseInt(year, 10) +} + +const isThisYearInTimezone = (timestamp: number, timezone: Timezone): boolean => + getZonedYear(timestamp, timezone) === getZonedYear(new Date(), timezone) + +const getZonedMonth = (timestamp: number, timezone: Timezone): number => { + const month = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + month: 'numeric', + }).format(timestamp) + + return Number.parseInt(month, 10) +} + +const getZonedDayOfMonth = (timestamp: number, timezone: Timezone): number => { + const day = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + day: 'numeric', + }).format(timestamp) + + return Number.parseInt(day, 10) +} + +const formatZonedDay = (timestamp: number, timezone: Timezone): string => + new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: isThisYearInTimezone(timestamp, timezone) ? undefined : 'numeric', + month: 'short', + day: 'numeric', + }).format(timestamp) + +const formatZonedHour = (timestamp: number, timezone: Timezone): string => { + const date = new Date(timestamp) + const day = formatZonedDay(timestamp, timezone) + const hour = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + hour: 'numeric', + hour12: true, + }) + .format(date) + .replace(/\s/g, '') + .toLowerCase() + + return `${day}, ${hour}` +} + +const formatZonedDateRange = ( + startTimestamp: number, + endTimestamp: number, + timezone: Timezone +): string => { + const startYear = getZonedYear(startTimestamp, timezone) + const endYear = getZonedYear(endTimestamp, timezone) + const sameYear = startYear === endYear + const sameMonth = + sameYear && + getZonedMonth(startTimestamp, timezone) === + getZonedMonth(endTimestamp, timezone) + + const startFormat = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + month: 'short', + day: 'numeric', + ...(sameYear ? {} : { year: 'numeric' }), + }) + + const endFormat = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + month: 'short', + day: 'numeric', + year: isThisYearInTimezone(endTimestamp, timezone) ? undefined : 'numeric', + }) + + if (sameMonth) { + return `${startFormat.format(startTimestamp)} - ${getZonedDayOfMonth(endTimestamp, timezone)}` + } + + return `${startFormat.format(startTimestamp)} - ${endFormat.format(endTimestamp)}` +} + /** * Format a timestamp to a human-readable date using Intl.DateTimeFormat * @param timestamp - Unix timestamp in milliseconds @@ -24,11 +106,13 @@ import type { */ export function formatAxisDate( timestamp: number, - samplingMode: SamplingMode + samplingMode: SamplingMode, + timezone: Timezone ): string { switch (samplingMode) { case 'hourly': return new Intl.DateTimeFormat('en-US', { + timeZone: timezone, month: 'short', day: 'numeric', hour: 'numeric', @@ -36,6 +120,7 @@ export function formatAxisDate( }).format(new Date(timestamp)) default: return new Intl.DateTimeFormat('en-US', { + timeZone: timezone, month: 'short', day: 'numeric', }).format(new Date(timestamp)) @@ -73,7 +158,8 @@ export function formatHoveredValues( vcpuHours: number, ramGibHours: number, timestamp: number, - timeframe: Timeframe + timeframe: Timeframe, + timezone: Timezone ): { sandboxes: DisplayValue cost: DisplayValue @@ -99,29 +185,29 @@ export function formatHoveredValues( switch (samplingMode) { case 'hourly': - timestampLabel = formatHour(timestamp) + timestampLabel = formatZonedHour(timestamp, timezone) label = 'at' break case 'daily': if (timestampIsAtStartEdge && timestampIsAtEndEdge) { // both edges in same bucket - show precise range - timestampLabel = `${formatHour(normalizedStartTimestamp)} - ${formatHour(normalizedEndTimestamp)}` + timestampLabel = `${formatZonedHour(normalizedStartTimestamp, timezone)} - ${formatZonedHour(normalizedEndTimestamp, timezone)}` label = 'during' } else if (timestampIsAtStartEdge) { // partial day at start - show from start hour to end of day const endOfDay = new Date(timestamp) endOfDay.setHours(23, 59, 59, 999) - timestampLabel = `${formatHour(timestamp)} - end of ${formatDay(timestamp)}` + timestampLabel = `${formatZonedHour(timestamp, timezone)} - end of ${formatZonedDay(timestamp, timezone)}` label = 'during' } else if (timestampIsAtEndEdge) { // partial day at end - show from start of day to end hour const startOfDay = new Date(timestamp) startOfDay.setHours(0, 0, 0, 0) - timestampLabel = `${formatDay(timestamp)} - ${formatHour(timestamp)}` + timestampLabel = `${formatZonedDay(timestamp, timezone)} - ${formatZonedHour(timestamp, timezone)}` label = 'during' } else { - timestampLabel = formatDay(timestamp) + timestampLabel = formatZonedDay(timestamp, timezone) label = 'on' } break @@ -129,21 +215,21 @@ export function formatHoveredValues( case 'weekly': if (timestampIsAtStartEdge && timestampIsAtEndEdge) { // both edges in same bucket - show precise range - timestampLabel = `${formatHour(normalizedStartTimestamp)} - ${formatHour(normalizedEndTimestamp)}` + timestampLabel = `${formatZonedHour(normalizedStartTimestamp, timezone)} - ${formatZonedHour(normalizedEndTimestamp, timezone)}` label = 'during' } else if (timestampIsAtStartEdge) { // partial week at start - show from start hour to end of week const weekEnd = normalizeToEndOfSamplingPeriod(timestamp, 'weekly') - timestampLabel = `${formatHour(timestamp)} - ${formatDay(weekEnd)}` + timestampLabel = `${formatZonedHour(timestamp, timezone)} - ${formatZonedDay(weekEnd, timezone)}` label = 'during' } else if (timestampIsAtEndEdge) { // partial week at end - show from start of week to end hour const weekStart = normalizeToStartOfSamplingPeriod(timestamp, 'weekly') - timestampLabel = `${formatDay(weekStart)} - ${formatHour(timestamp)}` + timestampLabel = `${formatZonedDay(weekStart, timezone)} - ${formatZonedHour(timestamp, timezone)}` label = 'during' } else { const weekEnd = normalizeToEndOfSamplingPeriod(timestamp, 'weekly') - timestampLabel = formatDateRange(timestamp, weekEnd) + timestampLabel = formatZonedDateRange(timestamp, weekEnd, timezone) label = 'during week' } break diff --git a/src/features/dashboard/usage/usage-charts-context.tsx b/src/features/dashboard/usage/usage-charts-context.tsx index 2bb2476c1..577616758 100644 --- a/src/features/dashboard/usage/usage-charts-context.tsx +++ b/src/features/dashboard/usage/usage-charts-context.tsx @@ -10,6 +10,7 @@ import { useState, } from 'react' import type { UsageResponse } from '@/core/modules/billing/models' +import { useTimezone } from '@/features/dashboard/timezone' import { fillTimeSeriesWithEmptyPoints } from '@/lib/utils/time-series' import { INITIAL_TIMEFRAME_FALLBACK_RANGE_MS } from './constants' import { @@ -75,6 +76,7 @@ export function UsageChartsProvider({ data, children, }: UsageChartsProviderProps) { + const { timezone } = useTimezone() // MUTABLE STATE const [params, setParams] = useQueryStates(timeframeParams, { @@ -159,23 +161,23 @@ export function UsageChartsProvider({ const displayedData = useMemo(() => { return { sandboxes: seriesData.sandboxes.map((d) => ({ - x: formatAxisDate(d.x, samplingMode), + x: formatAxisDate(d.x, samplingMode, timezone), y: d.y, })), cost: seriesData.cost.map((d) => ({ - x: formatAxisDate(d.x, samplingMode), + x: formatAxisDate(d.x, samplingMode, timezone), y: d.y, })), vcpu: seriesData.vcpu.map((d) => ({ - x: formatAxisDate(d.x, samplingMode), + x: formatAxisDate(d.x, samplingMode, timezone), y: d.y, })), ram: seriesData.ram.map((d) => ({ - x: formatAxisDate(d.x, samplingMode), + x: formatAxisDate(d.x, samplingMode, timezone), y: d.y, })), } - }, [seriesData, samplingMode]) + }, [seriesData, samplingMode, timezone]) const totals = useMemo( () => calculateTotals(sampledData), @@ -193,17 +195,24 @@ export function UsageChartsProvider({ ) const displayValues = useMemo(() => { - if ( - hoveredIndex !== null && - seriesData.sandboxes[hoveredIndex] !== undefined - ) { + if (hoveredIndex !== null) { + const sandboxPoint = seriesData.sandboxes[hoveredIndex] + const costPoint = seriesData.cost[hoveredIndex] + const vcpuPoint = seriesData.vcpu[hoveredIndex] + const ramPoint = seriesData.ram[hoveredIndex] + + if (!sandboxPoint || !costPoint || !vcpuPoint || !ramPoint) { + return formatTotalValues(totals) + } + return formatHoveredValues( - seriesData.sandboxes[hoveredIndex].y, - seriesData.cost[hoveredIndex]!.y, - seriesData.vcpu[hoveredIndex]!.y, - seriesData.ram[hoveredIndex]!.y, - seriesData.sandboxes[hoveredIndex]!.x, - timeframe + sandboxPoint.y, + costPoint.y, + vcpuPoint.y, + ramPoint.y, + sandboxPoint.x, + timeframe, + timezone ) } @@ -221,6 +230,7 @@ export function UsageChartsProvider({ seriesData.vcpu, seriesData.ram, timeframe, + timezone, ]) const setTimeframe = useCallback( @@ -232,13 +242,14 @@ export function UsageChartsProvider({ const onBrushEnd = useCallback( (startIndex: number, endIndex: number) => { + const startPoint = seriesData.sandboxes[startIndex] + const endPoint = seriesData.sandboxes[endIndex] + if (!startPoint || !endPoint) return + setHoveredIndex(null) setTimeframe( - seriesData.sandboxes[startIndex]!.x, - normalizeToEndOfSamplingPeriod( - seriesData.sandboxes[endIndex]!.x, - samplingMode - ) + startPoint.x, + normalizeToEndOfSamplingPeriod(endPoint.x, samplingMode) ) }, [seriesData.sandboxes, setTimeframe, samplingMode] diff --git a/tests/unit/usage-display-utils.test.ts b/tests/unit/usage-display-utils.test.ts new file mode 100644 index 000000000..ba13c717d --- /dev/null +++ b/tests/unit/usage-display-utils.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { parseTimezone, type Timezone } from '@/features/dashboard/timezone' +import { + formatAxisDate, + formatHoveredValues, +} from '@/features/dashboard/usage/display-utils' + +const requireTimezone = (value: string): Timezone => { + const timezone = parseTimezone(value) + if (!timezone) throw new Error(`Expected ${value} to be a valid timezone`) + + return timezone +} + +describe('usage display utilities', () => { + const newYork = requireTimezone('America/New_York') + const losAngeles = requireTimezone('America/Los_Angeles') + + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-10T16:00:00.000Z')) + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('formats hourly axis labels in the selected timezone', () => { + const timestamp = Date.UTC(2026, 5, 8, 13, 0, 0) + + expect(formatAxisDate(timestamp, 'hourly', newYork)).toContain('9 AM') + expect(formatAxisDate(timestamp, 'hourly', losAngeles)).toContain('6 AM') + }) + + it('formats hover timestamps in the selected timezone', () => { + const timestamp = Date.UTC(2026, 5, 8, 13, 0, 0) + const timeframe = { + start: timestamp, + end: timestamp + 60 * 60 * 1000, + } + + expect( + formatHoveredValues(1, 2, 3, 4, timestamp, timeframe, newYork).sandboxes + .timestamp + ).toBe('Jun 8, 9am') + expect( + formatHoveredValues(1, 2, 3, 4, timestamp, timeframe, losAngeles) + .sandboxes.timestamp + ).toBe('Jun 8, 6am') + }) +}) From f7460dc821aff0afd478cbd3cf576bfd5967a52f Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 11 Jun 2026 15:03:27 -0400 Subject: [PATCH 06/51] Complete dashboard timezone display coverage --- .../dashboard/sandbox/events/table.tsx | 3 + .../dashboard/sandbox/header/ended-at.tsx | 22 +- .../dashboard/sandbox/header/started-at.tsx | 19 +- .../dashboard/sandbox/logs/logs-cells.tsx | 33 ++- .../monitoring/components/chart-overlays.tsx | 11 +- .../monitoring-sandbox-metrics-chart.tsx | 8 +- .../components/use-chart-overlays.ts | 7 +- .../monitoring/utils/chart-data-utils.ts | 22 +- .../sandbox/monitoring/utils/formatters.ts | 16 +- .../dashboard/sandboxes/list/table-cells.tsx | 6 +- .../charts/concurrent-chart/hooks.ts | 22 +- .../charts/startrate-chart/hooks.ts | 14 +- .../charts/team-metrics-chart/index.tsx | 24 ++- .../dashboard/templates/list/table-cells.tsx | 27 ++- src/features/dashboard/timezone/date-time.ts | 73 +++++++ src/features/dashboard/timezone/index.ts | 4 + src/features/dashboard/usage/display-utils.ts | 42 ++-- .../dashboard/usage/sampling-utils.ts | 195 +++++++++++++----- .../dashboard/usage/usage-charts-context.tsx | 16 +- .../usage/usage-time-range-controls.tsx | 16 +- src/lib/utils/formatting.ts | 98 +++++---- tests/unit/timezone-date-time.test.ts | 55 ++++- tests/unit/usage-sampling-utils.test.ts | 91 ++++++++ 23 files changed, 617 insertions(+), 207 deletions(-) create mode 100644 tests/unit/usage-sampling-utils.test.ts diff --git a/src/features/dashboard/sandbox/events/table.tsx b/src/features/dashboard/sandbox/events/table.tsx index cf52711d4..8d8f838ed 100644 --- a/src/features/dashboard/sandbox/events/table.tsx +++ b/src/features/dashboard/sandbox/events/table.tsx @@ -13,6 +13,7 @@ import { VirtualizedTableRow, } from '@/features/dashboard/common/virtualized-table-ui' import { IdBadge } from '@/features/dashboard/shared' +import { useTimezone } from '@/features/dashboard/timezone' import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting' import CopyButtonInline from '@/ui/copy-button-inline' import { JsonPopover } from '@/ui/json-popover' @@ -161,8 +162,10 @@ const SandboxEventRow = ({ virtualRow, virtualizer, }: SandboxEventRowProps) => { + const { timezone } = useTimezone() const formattedTimestamp = formatLocalLogStyleTimestamp(event.timestamp, { includeCentiseconds: true, + timeZone: timezone, }) 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 0121d1a3c..0d728e2a1 100644 --- a/src/features/dashboard/sandbox/header/ended-at.tsx +++ b/src/features/dashboard/sandbox/header/ended-at.tsx @@ -1,31 +1,39 @@ 'use client' -import { Timestamp } from '@/features/dashboard/shared' +import { + formatZonedRelativeDayTime, + useTimezone, +} from '@/features/dashboard/timezone' 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

- } + if (!endedAt) return

N/A

+ + const { prefix, time: timeStr } = formatZonedRelativeDayTime( + endedAt, + timezone + ) return (
- +

+ {prefix}, {timeStr} +

{ +export default function StartedAt() { const { sandboxLifecycle } = useSandboxContext() + const { timezone } = useTimezone() const startedAt = sandboxLifecycle?.createdAt if (!startedAt) return null + const { prefix, time: timeStr } = formatZonedRelativeDayTime( + startedAt, + timezone + ) + return (
- +

+ {prefix}, {timeStr} +

) } - -export default StartedAt diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx index 26788fbfe..51fb96b35 100644 --- a/src/features/dashboard/sandbox/logs/logs-cells.tsx +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -1,6 +1,8 @@ +'use client' + import type { SandboxLogModel } from '@/core/modules/sandboxes/models' import { LogLevelBadge } from '@/features/dashboard/common/log-cells' -import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting' +import { useTimezone } from '@/features/dashboard/timezone' import CopyButtonInline from '@/ui/copy-button-inline' export const LogLevel = ({ level }: { level: SandboxLogModel['level'] }) => { @@ -12,21 +14,32 @@ interface TimestampProps { } export const Timestamp = ({ timestampUnix }: TimestampProps) => { - const formatted = formatLocalLogStyleTimestamp(timestampUnix, { - includeCentiseconds: true, - }) + const { timezone } = useTimezone() + const date = new Date(timestampUnix) - if (!formatted) { - return -- - } + const centiseconds = Math.floor((date.getMilliseconds() / 10) % 100) + .toString() + .padStart(2, '0') + const localDatePart = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', + timeZone: timezone, + }).format(date) + const localTimePart = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + timeZone: timezone, + }).format(date) return ( - {formatted.datePart}{' '} - {formatted.timePart}.{formatted.subsecondPart} + {localDatePart} {localTimePart}. + {centiseconds} ) } diff --git a/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx b/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx index 51bc27a39..0f7b27147 100644 --- a/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx @@ -1,3 +1,4 @@ +import type { Timezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' import { AddIcon, @@ -34,9 +35,11 @@ const SANDBOX_LIFECYCLE_EVENT_ICON_MAP: Record = { function LifecycleEventOverlayGroup({ overlays, showEventLabels, + timezone, }: { overlays: LifecycleEventOverlay[] showEventLabels: boolean + timezone: Timezone }) { 'use no memo' @@ -114,7 +117,10 @@ function LifecycleEventOverlayGroup({ {eventOverlay.label} - {formatHoverTimestamp(eventOverlay.timestampMs)} + {formatHoverTimestamp( + eventOverlay.timestampMs, + timezone + )}
@@ -205,6 +211,7 @@ export function ChartOverlayLayer({ showEventLabels, isMobile, axisPointerColor, + timezone, }: { lifecycleEventOverlays: LifecycleEventOverlay[] crosshairMarkers: CrosshairMarker[] @@ -212,6 +219,7 @@ export function ChartOverlayLayer({ showEventLabels: boolean isMobile: boolean axisPointerColor: string + timezone: Timezone }) { 'use no memo' @@ -229,6 +237,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 6375c6e6a..29cbcd66d 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 @@ -19,6 +19,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' @@ -158,6 +159,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) => @@ -502,7 +504,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, @@ -558,6 +561,7 @@ function SandboxMetricsChart({ xAxisMax, xAxisMin, yAxisFormatter, + timezone, ]) // echarts-for-react uses merge mode (notMerge: false) by default, which @@ -599,6 +603,7 @@ function SandboxMetricsChart({ computedYAxisMax, cssVars, yAxisFormatter, + timezone, }) return ( @@ -619,6 +624,7 @@ function SandboxMetricsChart({ showEventLabels={showEventLabels} isMobile={isMobile} axisPointerColor={axisPointerColor} + timezone={timezone} />
) diff --git a/src/features/dashboard/sandbox/monitoring/components/use-chart-overlays.ts b/src/features/dashboard/sandbox/monitoring/components/use-chart-overlays.ts index e3f83b7bd..04d22d0fb 100644 --- a/src/features/dashboard/sandbox/monitoring/components/use-chart-overlays.ts +++ b/src/features/dashboard/sandbox/monitoring/components/use-chart-overlays.ts @@ -1,5 +1,6 @@ import type * as echarts from 'echarts/core' import { type RefObject, useMemo } from 'react' +import type { Timezone } from '@/features/dashboard/timezone' import type { SandboxMetricsLifecycleEventMarker, SandboxMetricsSeries, @@ -67,6 +68,7 @@ interface UseChartOverlaysOptions { computedYAxisMax: number cssVars: Record 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..7fecf72f5 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,7 @@ +import { + formatZonedTimeAxisLabel, + type Timezone, +} from '@/features/dashboard/timezone' import type { SandboxMetricsDataPoint } from '../types/sandbox-metrics-chart' export function toNumericValue(value: unknown): number { @@ -14,25 +18,11 @@ 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 formatZonedTimeAxisLabel(timestamp, timezone, includeSeconds) } export function findLivePoint( diff --git a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts index 6818b1073..ff22b506d 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts @@ -1,15 +1,17 @@ +import type { Timezone } from '@/features/dashboard/timezone' 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)) +export function formatHoverTimestamp( + timestampMs: number, + timezone: Timezone +): string { + return new Intl.DateTimeFormat(undefined, { + ...SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, + timeZone: timezone, + }).format(new Date(timestampMs)) } export function clampPercent(value: number): number { diff --git a/src/features/dashboard/sandboxes/list/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx index ff61a40eb..5ce47fd40 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -5,6 +5,7 @@ import Link from 'next/link' import { useMemo } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import ResourceUsage from '@/features/dashboard/common/resource-usage' +import { useTimezone } from '@/features/dashboard/timezone' import { formatLocalLogStyleTimestamp } from '@/lib/utils/formatting' import { JsonPopover } from '@/ui/json-popover' import { Badge } from '@/ui/primitives/badge' @@ -233,11 +234,12 @@ export function MetadataCell({ export function StartedAtCell({ getValue, }: CellContext) { + const { timezone } = useTimezone() const dateValue = (getValue() as string | undefined) ?? '' const formattedTimestamp = useMemo(() => { - return formatLocalLogStyleTimestamp(dateValue, { includeTimezone: true }) - }, [dateValue]) + return formatLocalLogStyleTimestamp(dateValue, { timeZone: 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..f4a168e8a 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 { + formatZonedCompactDate, + formatZonedDateRange, + useTimezone, +} from '@/features/dashboard/timezone' +import { 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 = formatZonedCompactDate( + hoveredValue.timestamp, + timezone + ) 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 formatZonedDateRange(timeframe.start, timeframe.end, timezone, { + includeTime: true, + includeTimezone: true, + }) } 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..8aad7abf1 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,10 @@ import { useMemo } from 'react' import type { TeamMetricsResponse } from '@/core/modules/sandboxes/models.client' -import { formatCompactDate, formatDecimal } from '@/lib/utils/formatting' +import { + formatZonedCompactDate, + useTimezone, +} from '@/features/dashboard/timezone' +import { formatDecimal } from '@/lib/utils/formatting' import { transformMetrics } from '../team-metrics-chart' import { calculateAverage } from '../team-metrics-chart/utils' @@ -21,11 +25,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 = formatZonedCompactDate( + hoveredValue.timestamp, + timezone + ) return { displayValue: formatDecimal(hoveredValue.sandboxStartRate, 3), label: 'at', @@ -37,5 +45,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 2af4f9757..133ad22ce 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 @@ -20,6 +20,10 @@ 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 { + createZonedTimeAxisLabelFormatter, + 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' @@ -65,6 +69,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) @@ -175,6 +180,15 @@ function TeamMetricsChart({ } }, []) + const xAxisLabelFormatter = useMemo( + () => + createZonedTimeAxisLabelFormatter( + 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 @@ -307,14 +321,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, @@ -380,6 +387,7 @@ function TeamMetricsChart({ errorHighlight, errorBg, bg1, + xAxisLabelFormatter, ]) return ( diff --git a/src/features/dashboard/templates/list/table-cells.tsx b/src/features/dashboard/templates/list/table-cells.tsx index 7bcc5574b..38b820c63 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, @@ -329,12 +330,6 @@ export function TemplateNameCell({ type DateColumnId = 'createdAt' | 'updatedAt' -const DATE_CELL_FORMAT_OPTIONS = { - includeSeconds: false, - includeYear: true, - includeTimezone: true, -} as const - function DateTimeCell({ ctx, columnId, @@ -345,11 +340,21 @@ function DateTimeCell({ 'use no memo' const { getValue, table, row } = ctx + const { timezone } = useTimezone() const dateValue = getValue() as string + const formatOptions = useMemo( + () => ({ + includeSeconds: false, + includeYear: true, + timeZone: timezone, + }), + [timezone] + ) + const formatted = useMemo( - () => formatLocalLogStyleTimestamp(dateValue, DATE_CELL_FORMAT_OPTIONS), - [dateValue] + () => formatLocalLogStyleTimestamp(dateValue, formatOptions), + [dateValue, formatOptions] ) const rows = table.getRowModel().rows @@ -363,8 +368,8 @@ function DateTimeCell({ if (!neighbor) return false const neighborValue = neighbor.getValue(columnId) as string return ( - formatLocalLogStyleTimestamp(neighborValue, DATE_CELL_FORMAT_OPTIONS) - ?.datePart === datePart + formatLocalLogStyleTimestamp(neighborValue, formatOptions)?.datePart === + datePart ) } @@ -375,7 +380,7 @@ function DateTimeCell({ isDateEmphasized: !sameAsAbove, isTimeEmphasized: sameAsAbove || sameAsBelow, } - }, [datePart, rows, row.index, columnId]) + }, [datePart, rows, row.index, columnId, formatOptions]) return (
"Jun 8, 9:00:00 AM EDT". +const formatZonedCompactDate = ( + timestamp: number, + timezone: Timezone +): string => { + const timestampYear = formatInTimeZone(timestamp, timezone, 'yyyy') + const currentYear = formatInTimeZone(Date.now(), timezone, 'yyyy') + + if (timestampYear === currentYear) { + return formatInTimeZone(timestamp, timezone, 'MMM d, h:mm:ss a zzz') + } + + return formatInTimeZone(timestamp, timezone, 'yyyy MMM d, h:mm:ss a zzz') +} + +// Formats a chart x-axis time label; e.g. 2026-06-08T13:05:09Z in America/New_York -> "09:05" or "09:05:09". +const formatZonedTimeAxisLabel = ( + timestamp: number, + timezone: Timezone, + includeSeconds = false +): string => { + if (Number.isNaN(timestamp)) return '' + + return formatInTimeZone( + timestamp, + timezone, + includeSeconds ? 'HH:mm:ss' : 'HH:mm' + ) +} + +// Picks an ECharts-style time axis label format based on visible range duration. +const createZonedTimeAxisLabelFormatter = ( + timezone: Timezone, + rangeMs: number +): ((value: number) => string) => { + const format = + rangeMs > 365 * 24 * 60 * 60 * 1000 + ? 'yyyy' + : rangeMs > 2 * 24 * 60 * 60 * 1000 + ? 'MMM d' + : 'HH:mm' + + return (value: number) => formatInTimeZone(value, timezone, format) +} + +// Formats a relative day prefix and time in the selected timezone; e.g. today in America/New_York -> { prefix: "Today", time: "9:00:00 AM" }. +const formatZonedRelativeDayTime = ( + value: string | number | Date, + timezone: Timezone +): { prefix: string; time: string } => { + const valueKey = formatInTimeZone(value, timezone, 'yyyy-MM-dd') + const todayKey = formatInTimeZone(Date.now(), timezone, 'yyyy-MM-dd') + const yesterdayKey = formatInTimeZone( + Date.now() - 24 * 60 * 60 * 1000, + timezone, + 'yyyy-MM-dd' + ) + + let prefix: string + if (valueKey === todayKey) prefix = 'Today' + else if (valueKey === yesterdayKey) prefix = 'Yesterday' + else prefix = formatInTimeZone(value, timezone, 'PP') + + return { + prefix, + time: formatInTimeZone(value, timezone, 'h:mm:ss a'), + } +} + export { + createZonedTimeAxisLabelFormatter, formatTimezoneAbbreviation, + formatZonedCompactDate, formatZonedDateRange, formatZonedDateTimeInput, + formatZonedRelativeDayTime, + formatZonedTimeAxisLabel, zonedDateTimePartsToUtcDate, zonedDateTimePartsToUtcTimestamp, } diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index 649a098b6..36977d578 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -4,9 +4,13 @@ export type { ZonedDateTimeParts, } from './date-time' export { + createZonedTimeAxisLabelFormatter, formatTimezoneAbbreviation, + formatZonedCompactDate, formatZonedDateRange, formatZonedDateTimeInput, + formatZonedRelativeDayTime, + formatZonedTimeAxisLabel, zonedDateTimePartsToUtcDate, zonedDateTimePartsToUtcTimestamp, } from './date-time' diff --git a/src/features/dashboard/usage/display-utils.ts b/src/features/dashboard/usage/display-utils.ts index ea291a45a..849603c70 100644 --- a/src/features/dashboard/usage/display-utils.ts +++ b/src/features/dashboard/usage/display-utils.ts @@ -134,18 +134,20 @@ export function formatAxisDate( */ export function formatBucketLabel( timestamp: number, - samplingMode: SamplingMode + samplingMode: SamplingMode, + timezone: Timezone ): string { switch (samplingMode) { case 'hourly': - return formatHour(timestamp) + return formatZonedHour(timestamp, timezone) case 'weekly': - return formatDateRange( + return formatZonedDateRange( timestamp, - normalizeToEndOfSamplingPeriod(timestamp, 'weekly') + normalizeToEndOfSamplingPeriod(timestamp, 'weekly', timezone), + timezone ) default: - return formatDay(timestamp) + return formatZonedDay(timestamp, timezone) } } @@ -173,11 +175,13 @@ export function formatHoveredValues( // edge bucket keys match the hour containing the timeframe boundary const normalizedStartTimestamp = normalizeToStartOfSamplingPeriod( timeframe.start, - 'hourly' + 'hourly', + timezone ) const normalizedEndTimestamp = normalizeToStartOfSamplingPeriod( timeframe.end, - 'hourly' + 'hourly', + timezone ) const timestampIsAtStartEdge = timestamp === normalizedStartTimestamp @@ -195,15 +199,9 @@ export function formatHoveredValues( timestampLabel = `${formatZonedHour(normalizedStartTimestamp, timezone)} - ${formatZonedHour(normalizedEndTimestamp, timezone)}` label = 'during' } else if (timestampIsAtStartEdge) { - // partial day at start - show from start hour to end of day - const endOfDay = new Date(timestamp) - endOfDay.setHours(23, 59, 59, 999) timestampLabel = `${formatZonedHour(timestamp, timezone)} - end of ${formatZonedDay(timestamp, timezone)}` label = 'during' } else if (timestampIsAtEndEdge) { - // partial day at end - show from start of day to end hour - const startOfDay = new Date(timestamp) - startOfDay.setHours(0, 0, 0, 0) timestampLabel = `${formatZonedDay(timestamp, timezone)} - ${formatZonedHour(timestamp, timezone)}` label = 'during' } else { @@ -219,16 +217,28 @@ export function formatHoveredValues( label = 'during' } else if (timestampIsAtStartEdge) { // partial week at start - show from start hour to end of week - const weekEnd = normalizeToEndOfSamplingPeriod(timestamp, 'weekly') + const weekEnd = normalizeToEndOfSamplingPeriod( + timestamp, + 'weekly', + timezone + ) timestampLabel = `${formatZonedHour(timestamp, timezone)} - ${formatZonedDay(weekEnd, timezone)}` label = 'during' } else if (timestampIsAtEndEdge) { // partial week at end - show from start of week to end hour - const weekStart = normalizeToStartOfSamplingPeriod(timestamp, 'weekly') + const weekStart = normalizeToStartOfSamplingPeriod( + timestamp, + 'weekly', + timezone + ) timestampLabel = `${formatZonedDay(weekStart, timezone)} - ${formatZonedHour(timestamp, timezone)}` label = 'during' } else { - const weekEnd = normalizeToEndOfSamplingPeriod(timestamp, 'weekly') + const weekEnd = normalizeToEndOfSamplingPeriod( + timestamp, + 'weekly', + timezone + ) timestampLabel = formatZonedDateRange(timestamp, weekEnd, timezone) label = 'during week' } diff --git a/src/features/dashboard/usage/sampling-utils.ts b/src/features/dashboard/usage/sampling-utils.ts index d5cec5b80..382518c60 100644 --- a/src/features/dashboard/usage/sampling-utils.ts +++ b/src/features/dashboard/usage/sampling-utils.ts @@ -1,11 +1,97 @@ -import { startOfISOWeek } from 'date-fns' import type { UsageResponse } from '@/core/modules/billing/models' +import { + type Timezone, + zonedDateTimePartsToUtcTimestamp, +} from '@/features/dashboard/timezone' import { HOURLY_SAMPLING_THRESHOLD_DAYS, WEEKLY_SAMPLING_THRESHOLD_DAYS, } from './constants' import type { SampledDataPoint, SamplingMode, Timeframe } from './types' +interface ZonedDateTimeParts { + year: number + month: number + day: number + hours: number + minutes: number + seconds: number +} + +const getZonedDateTimeParts = ( + timestamp: number, + timezone: Timezone +): ZonedDateTimeParts => { + const parts = new Intl.DateTimeFormat('en-US', { + timeZone: timezone, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hourCycle: 'h23', + }) + .formatToParts(timestamp) + .reduce>((result, part) => { + if ( + part.type === 'year' || + part.type === 'month' || + part.type === 'day' || + part.type === 'hour' || + part.type === 'minute' || + part.type === 'second' + ) { + result[part.type] = part.value + } + + return result + }, {}) + + const year = Number.parseInt(parts.year ?? '', 10) + const month = Number.parseInt(parts.month ?? '', 10) + const day = Number.parseInt(parts.day ?? '', 10) + const hours = Number.parseInt(parts.hour ?? '', 10) + const minutes = Number.parseInt(parts.minute ?? '', 10) + const seconds = Number.parseInt(parts.second ?? '', 10) + + if ( + Number.isNaN(year) || + Number.isNaN(month) || + Number.isNaN(day) || + Number.isNaN(hours) || + Number.isNaN(minutes) || + Number.isNaN(seconds) + ) { + throw new Error('Unable to format usage sampling timestamp') + } + + return { year, month, day, hours, minutes, seconds } +} + +// Shifts a selected-zone calendar date without applying browser timezone rules; e.g. Tuesday + -1 -> Monday. +const shiftCalendarDays = ( + parts: Pick, + days: number +): Pick => { + const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)) + date.setUTCDate(date.getUTCDate() + days) + + return { + year: date.getUTCFullYear(), + month: date.getUTCMonth() + 1, + day: date.getUTCDate(), + } +} + +const getIsoWeekStartParts = ( + parts: Pick +): Pick => { + const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)) + const daysSinceMonday = (date.getUTCDay() + 6) % 7 + return shiftCalendarDays(parts, -daysSinceMonday) +} + export function determineSamplingMode(timeframe: Timeframe): SamplingMode { const rangeDays = (timeframe.end - timeframe.start) / (24 * 60 * 60 * 1000) @@ -33,57 +119,91 @@ export function getSamplingModeStepMs(samplingMode: SamplingMode): number { export function processUsageData( hourlyData: UsageResponse['hour_usages'], - timeframe: Timeframe + timeframe: Timeframe, + timezone: Timezone ): SampledDataPoint[] { if (!hourlyData || hourlyData.length === 0) { return [] } - return aggregateHours(hourlyData, timeframe) + return aggregateHours(hourlyData, timeframe, timezone) } export function normalizeToStartOfSamplingPeriod( timestamp: number, - mode: SamplingMode + mode: SamplingMode, + timezone: Timezone ): number { - const date = new Date(timestamp) + const parts = getZonedDateTimeParts(timestamp, timezone) switch (mode) { case 'hourly': - return date.setMinutes(0, 0, 0) + return zonedDateTimePartsToUtcTimestamp( + { ...parts, minutes: 0, seconds: 0 }, + timezone + ) case 'daily': - date.setMinutes(0, 0, 0) - return date.setHours(0, 0, 0, 0) + return zonedDateTimePartsToUtcTimestamp( + { ...parts, hours: 0, minutes: 0, seconds: 0 }, + timezone + ) - case 'weekly': - date.setMinutes(0, 0, 0) - date.setHours(0, 0, 0, 0) + case 'weekly': { + const weekStart = getIsoWeekStartParts(parts) - return startOfISOWeek(date).getTime() + return zonedDateTimePartsToUtcTimestamp( + { + ...weekStart, + hours: 0, + minutes: 0, + seconds: 0, + }, + timezone + ) + } } } export function normalizeToEndOfSamplingPeriod( timestamp: number, - mode: SamplingMode + mode: SamplingMode, + timezone: Timezone ): number { - const date = new Date(timestamp) + const parts = getZonedDateTimeParts(timestamp, timezone) switch (mode) { case 'hourly': - date.setMinutes(59, 59, 999) - return date.getTime() + return ( + zonedDateTimePartsToUtcTimestamp( + { ...parts, minutes: 59, seconds: 59 }, + timezone + ) + 999 + ) case 'daily': - date.setHours(23, 59, 59, 999) - return date.getTime() + return ( + zonedDateTimePartsToUtcTimestamp( + { ...parts, hours: 23, minutes: 59, seconds: 59 }, + timezone + ) + 999 + ) case 'weekly': { - const weekStart = startOfISOWeek(date) - weekStart.setDate(weekStart.getDate() + 6) - weekStart.setHours(23, 59, 59, 999) - return weekStart.getTime() + const weekStart = getIsoWeekStartParts(parts) + const weekEnd = shiftCalendarDays(weekStart, 6) + + return ( + zonedDateTimePartsToUtcTimestamp( + { + ...weekEnd, + hours: 23, + minutes: 59, + seconds: 59, + }, + timezone + ) + 999 + ) } } } @@ -95,7 +215,8 @@ export function normalizeToEndOfSamplingPeriod( */ function aggregateHours( hourlyData: UsageResponse['hour_usages'], - timeframe: Timeframe + timeframe: Timeframe, + timezone: Timezone ): SampledDataPoint[] { const samplingMode = determineSamplingMode(timeframe) @@ -112,34 +233,8 @@ function aggregateHours( })) } - // pre-calculate sampling period boundaries for edge bucket detection - const timeframeStartPeriod = normalizeToStartOfSamplingPeriod( - timeframe.start, - samplingMode - ) - const timeframeEndPeriod = normalizeToStartOfSamplingPeriod( - timeframe.end, - samplingMode - ) - function createBucketKey(timestamp: number): number { - const timestampPeriodStart = normalizeToStartOfSamplingPeriod( - timestamp, - samplingMode - ) - - // // check if timestamp is in the same sampling period as timeframe.start - // if (timestampPeriodStart === timeframeStartPeriod) { - // return normalizeToStartOfSamplingPeriod(timeframe.start, 'hourly') - // } - - // // check if timestamp is in the same sampling period as timeframe.end - // if (timestampPeriodStart === timeframeEndPeriod) { - // return normalizeToStartOfSamplingPeriod(timeframe.end, 'hourly') - // } - - // middle bucket: use full sampling period - return timestampPeriodStart + return normalizeToStartOfSamplingPeriod(timestamp, samplingMode, timezone) } // group data by timestamp buckets diff --git a/src/features/dashboard/usage/usage-charts-context.tsx b/src/features/dashboard/usage/usage-charts-context.tsx index 577616758..3b338de13 100644 --- a/src/features/dashboard/usage/usage-charts-context.tsx +++ b/src/features/dashboard/usage/usage-charts-context.tsx @@ -133,8 +133,9 @@ export function UsageChartsProvider({ }, [data.hour_usages, timeframe]) const sampledData = useMemo( - () => processUsageData(zeroFilledTimeframeFilteredData, timeframe), - [zeroFilledTimeframeFilteredData, timeframe] + () => + processUsageData(zeroFilledTimeframeFilteredData, timeframe, timezone), + [zeroFilledTimeframeFilteredData, timeframe, timezone] ) const seriesData = useMemo(() => { @@ -190,8 +191,11 @@ export function UsageChartsProvider({ ) const bucketLabels = useMemo( - () => sampledData.map((d) => formatBucketLabel(d.timestamp, samplingMode)), - [sampledData, samplingMode] + () => + sampledData.map((d) => + formatBucketLabel(d.timestamp, samplingMode, timezone) + ), + [sampledData, samplingMode, timezone] ) const displayValues = useMemo(() => { @@ -249,10 +253,10 @@ export function UsageChartsProvider({ setHoveredIndex(null) setTimeframe( startPoint.x, - normalizeToEndOfSamplingPeriod(endPoint.x, samplingMode) + normalizeToEndOfSamplingPeriod(endPoint.x, samplingMode, timezone) ) }, - [seriesData.sandboxes, setTimeframe, samplingMode] + [seriesData.sandboxes, setTimeframe, samplingMode, timezone] ) const value = useMemo( diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index 3ef453e67..7987334eb 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -111,14 +111,16 @@ export function UsageTimeRangeControls({ onTimeRangeChange( normalizeToStartOfSamplingPeriod( timeframe.start - quarterOfRangeDuration, - samplingMode + samplingMode, + timezone ), normalizeToEndOfSamplingPeriod( timeframe.end - quarterOfRangeDuration, - samplingMode + samplingMode, + timezone ) ) - }, [timeframe, quarterOfRangeDuration, onTimeRangeChange]) + }, [timeframe, quarterOfRangeDuration, onTimeRangeChange, timezone]) const handleNextRange = useCallback(() => { const samplingMode = determineSamplingMode(timeframe) @@ -126,14 +128,16 @@ export function UsageTimeRangeControls({ onTimeRangeChange( normalizeToStartOfSamplingPeriod( timeframe.start + quarterOfRangeDuration, - samplingMode + samplingMode, + timezone ), normalizeToEndOfSamplingPeriod( timeframe.end + quarterOfRangeDuration, - samplingMode + samplingMode, + timezone ) ) - }, [timeframe, quarterOfRangeDuration, onTimeRangeChange]) + }, [timeframe, quarterOfRangeDuration, onTimeRangeChange, timezone]) const handleTimeRangeApply = useCallback( (start: number, end: number) => { diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts index 529152c4c..81de3e7e1 100644 --- a/src/lib/utils/formatting.ts +++ b/src/lib/utils/formatting.ts @@ -7,40 +7,6 @@ import * as chrono from 'chrono-node' import { format, isThisYear, isValid } from 'date-fns' import { formatInTimeZone } from 'date-fns-tz' -const LOCAL_LOG_STYLE_DATE_FORMATTER = new Intl.DateTimeFormat('en-US', { - month: 'short', - day: '2-digit', -}) - -const LOCAL_LOG_STYLE_DATE_WITH_YEAR_FORMATTER = new Intl.DateTimeFormat( - 'en-US', - { - month: 'short', - day: '2-digit', - year: 'numeric', - } -) - -const LOCAL_LOG_STYLE_TIME_FORMATTER = new Intl.DateTimeFormat('en-US', { - hour: '2-digit', - minute: '2-digit', - second: '2-digit', - hour12: false, -}) - -const LOCAL_LOG_STYLE_TIME_NO_SECONDS_FORMATTER = new Intl.DateTimeFormat( - 'en-US', - { - hour: '2-digit', - minute: '2-digit', - hour12: false, - } -) - -const LOCAL_LOG_STYLE_TIMEZONE_FORMATTER = new Intl.DateTimeFormat('en-US', { - timeZoneName: 'short', -}) - // ============================================================================ // Date & Time Formatting // ============================================================================ @@ -55,12 +21,12 @@ export function formatLocalLogStyleTimestamp( includeSeconds = true, includeYear = false, includeCentiseconds = false, - includeTimezone = false, + timeZone, }: { includeSeconds?: boolean includeYear?: boolean includeCentiseconds?: boolean - includeTimezone?: boolean + timeZone?: string } = {} ): { datePart: string @@ -75,23 +41,53 @@ export function formatLocalLogStyleTimestamp( return null } - const timezonePart = includeTimezone - ? (LOCAL_LOG_STYLE_TIMEZONE_FORMATTER.formatToParts(date).find( - (part) => part.type === 'timeZoneName' - )?.value ?? - Intl.DateTimeFormat().resolvedOptions().timeZone ?? - 'Local') - : null + const dateFormatterOptions: Intl.DateTimeFormatOptions = timeZone + ? { timeZone } + : {} + const dateFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', + ...dateFormatterOptions, + }) + const dateWithYearFormatter = new Intl.DateTimeFormat(undefined, { + month: 'short', + day: '2-digit', + year: 'numeric', + ...dateFormatterOptions, + }) + const timeFormatter = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + ...dateFormatterOptions, + }) + const timeNoSecondsFormatter = new Intl.DateTimeFormat(undefined, { + hour: '2-digit', + minute: '2-digit', + hour12: false, + ...dateFormatterOptions, + }) + const timezoneFormatter = new Intl.DateTimeFormat(undefined, { + timeZoneName: 'short', + ...dateFormatterOptions, + }) + + const timezonePart = + timezoneFormatter + .formatToParts(date) + .find((part) => part.type === 'timeZoneName')?.value ?? + timeZone ?? + Intl.DateTimeFormat().resolvedOptions().timeZone ?? + 'Local' return { - datePart: (includeYear - ? LOCAL_LOG_STYLE_DATE_WITH_YEAR_FORMATTER - : LOCAL_LOG_STYLE_DATE_FORMATTER - ).format(date), - timePart: (includeSeconds - ? LOCAL_LOG_STYLE_TIME_FORMATTER - : LOCAL_LOG_STYLE_TIME_NO_SECONDS_FORMATTER - ).format(date), + datePart: (includeYear ? dateWithYearFormatter : dateFormatter).format( + date + ), + timePart: (includeSeconds ? timeFormatter : timeNoSecondsFormatter).format( + date + ), subsecondPart: includeCentiseconds ? Math.floor((date.getMilliseconds() / 10) % 100) .toString() diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index 0d7cc3b0a..49f6add33 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -1,8 +1,12 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { + createZonedTimeAxisLabelFormatter, formatTimezoneAbbreviation, + formatZonedCompactDate, formatZonedDateRange, formatZonedDateTimeInput, + formatZonedRelativeDayTime, + formatZonedTimeAxisLabel, parseTimezone, type Timezone, zonedDateTimePartsToUtcDate, @@ -105,4 +109,53 @@ describe('timezone date-time helpers', () => { expect(result).toContain('EDT') }) }) + + describe('formatZonedCompactDate', () => { + it('formats compact timestamps in the selected timezone', () => { + expect( + formatZonedCompactDate(Date.UTC(2026, 5, 8, 13, 0, 0), newYork) + ).toContain('Jun 8') + expect( + formatZonedCompactDate(Date.UTC(2026, 5, 8, 13, 0, 0), newYork) + ).toContain('9:00:00') + }) + }) + + describe('formatZonedTimeAxisLabel', () => { + it('formats axis labels in the selected timezone', () => { + expect( + formatZonedTimeAxisLabel(Date.UTC(2026, 5, 8, 13, 5, 9), newYork) + ).toBe('09:05') + expect( + formatZonedTimeAxisLabel(Date.UTC(2026, 5, 8, 13, 5, 9), newYork, true) + ).toBe('09:05:09') + }) + }) + + describe('createZonedTimeAxisLabelFormatter', () => { + it('uses hour labels for short ranges', () => { + const formatter = createZonedTimeAxisLabelFormatter( + newYork, + 60 * 60 * 1000 + ) + + expect(formatter(Date.UTC(2026, 5, 8, 13, 0, 0))).toBe('09:00') + }) + }) + + describe('formatZonedRelativeDayTime', () => { + it('labels today and yesterday in the selected timezone', () => { + vi.useFakeTimers() + vi.setSystemTime(new Date('2026-06-10T16:00:00.000Z')) + + expect( + formatZonedRelativeDayTime('2026-06-10T16:00:00.000Z', newYork).prefix + ).toBe('Today') + expect( + formatZonedRelativeDayTime('2026-06-09T16:00:00.000Z', newYork).prefix + ).toBe('Yesterday') + + vi.useRealTimers() + }) + }) }) diff --git a/tests/unit/usage-sampling-utils.test.ts b/tests/unit/usage-sampling-utils.test.ts new file mode 100644 index 000000000..078192c42 --- /dev/null +++ b/tests/unit/usage-sampling-utils.test.ts @@ -0,0 +1,91 @@ +import { describe, expect, it } from 'vitest' +import { parseTimezone, type Timezone } from '@/features/dashboard/timezone' +import { + normalizeToEndOfSamplingPeriod, + normalizeToStartOfSamplingPeriod, + processUsageData, +} from '@/features/dashboard/usage/sampling-utils' + +const requireTimezone = (value: string): Timezone => { + const timezone = parseTimezone(value) + if (!timezone) throw new Error(`Expected ${value} to be a valid timezone`) + + return timezone +} + +describe('usage sampling utilities', () => { + const newYork = requireTimezone('America/New_York') + const losAngeles = requireTimezone('America/Los_Angeles') + + it('normalizes daily boundaries in the selected timezone', () => { + const timestamp = Date.UTC(2026, 5, 10, 13, 0, 0) + + expect( + new Date( + normalizeToStartOfSamplingPeriod(timestamp, 'daily', newYork) + ).toISOString() + ).toBe('2026-06-10T04:00:00.000Z') + expect( + new Date( + normalizeToStartOfSamplingPeriod(timestamp, 'daily', losAngeles) + ).toISOString() + ).toBe('2026-06-10T07:00:00.000Z') + + expect( + new Date( + normalizeToEndOfSamplingPeriod(timestamp, 'daily', newYork) + ).toISOString() + ).toBe('2026-06-11T03:59:59.999Z') + expect( + new Date( + normalizeToEndOfSamplingPeriod(timestamp, 'daily', losAngeles) + ).toISOString() + ).toBe('2026-06-11T06:59:59.999Z') + }) + + it('normalizes weekly boundaries in the selected timezone', () => { + const timestamp = Date.UTC(2026, 5, 10, 13, 0, 0) + + expect( + new Date( + normalizeToStartOfSamplingPeriod(timestamp, 'weekly', newYork) + ).toISOString() + ).toBe('2026-06-08T04:00:00.000Z') + expect( + new Date( + normalizeToEndOfSamplingPeriod(timestamp, 'weekly', newYork) + ).toISOString() + ).toBe('2026-06-15T03:59:59.999Z') + }) + + it('aggregates daily data into selected-timezone bucket starts', () => { + const timestamp = Date.UTC(2026, 5, 10, 13, 0, 0) + const sampledData = processUsageData( + [ + { + timestamp, + sandbox_count: 2, + cpu_hours: 3, + ram_gib_hours: 4, + price_for_cpu: 5, + price_for_ram: 6, + }, + ], + { + start: Date.UTC(2026, 5, 1, 0, 0, 0), + end: Date.UTC(2026, 5, 11, 0, 0, 0), + }, + losAngeles + ) + + expect(sampledData).toEqual([ + { + timestamp: Date.UTC(2026, 5, 10, 7, 0, 0), + sandboxCount: 2, + cost: 11, + vcpuHours: 3, + ramGibHours: 4, + }, + ]) + }) +}) From ad0afc255ab63097505877a171a695c5e47b671f Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 11 Jun 2026 15:19:49 -0400 Subject: [PATCH 07/51] Address timezone review feedback --- src/features/dashboard/timezone/context.tsx | 6 ++-- .../usage/usage-time-range-controls.tsx | 31 +------------------ 2 files changed, 5 insertions(+), 32 deletions(-) diff --git a/src/features/dashboard/timezone/context.tsx b/src/features/dashboard/timezone/context.tsx index 9b3810ff6..95551e79d 100644 --- a/src/features/dashboard/timezone/context.tsx +++ b/src/features/dashboard/timezone/context.tsx @@ -9,9 +9,11 @@ import { useMemo, useState, } from 'react' -import type { Timezone } from './schema' +import { type Timezone, TimezoneSchema } from './schema' import { getBrowserTimezone, parseTimezone } from './utils' +const DEFAULT_TIMEZONE = TimezoneSchema.parse('UTC') + interface TimezoneContextValue { timezone: Timezone setTimezone: (timezone: string) => Promise @@ -49,7 +51,7 @@ export const TimezoneProvider = ({ [initialTimezone] ) const [timezone, setTimezoneState] = useState( - parsedInitialTimezone ?? getBrowserTimezone() + parsedInitialTimezone ?? DEFAULT_TIMEZONE ) useEffect(() => { diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index 7987334eb..c45250614 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -1,6 +1,6 @@ 'use client' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useState } from 'react' import { formatZonedDateRange, useTimezone, @@ -46,8 +46,6 @@ export function UsageTimeRangeControls({ }: UsageTimeRangeControlsProps) { const { timezone } = useTimezone() const [isTimePickerOpen, setIsTimePickerOpen] = useState(false) - const lastMatchedPresetIdRef = useRef(undefined) - const previousTimezoneRef = useRef(timezone) const timeRangePresets = useMemo( () => getUsageTimeRangePresets(timezone), @@ -65,33 +63,6 @@ export function UsageTimeRangeControls({ [timeRangePresets, timeframe.start, timeframe.end] ) - useEffect(() => { - if (previousTimezoneRef.current === timezone) return - - previousTimezoneRef.current = timezone - const presetId = selectedPresetId ?? lastMatchedPresetIdRef.current - if (!presetId) return - - const preset = timeRangePresets.find((option) => option.id === presetId) - if (!preset) return - - const { start, end } = preset.getValue() - if (start === timeframe.start && end === timeframe.end) return - - onTimeRangeChange(start, end) - }, [ - onTimeRangeChange, - selectedPresetId, - timeframe.end, - timeframe.start, - timeRangePresets, - timezone, - ]) - - useEffect(() => { - lastMatchedPresetIdRef.current = selectedPresetId - }, [selectedPresetId]) - const rangeLabel = useMemo(() => { return formatZonedDateRange(timeframe.start, timeframe.end, timezone) }, [timeframe.start, timeframe.end, timezone]) From 83f3a28afd2730d7b95169ae098d7601b5b12226 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Thu, 11 Jun 2026 15:23:01 -0400 Subject: [PATCH 08/51] Fix timezone settings hydration --- .../dashboard/account/timezone-settings.tsx | 27 ++++++++++++++----- 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/src/features/dashboard/account/timezone-settings.tsx b/src/features/dashboard/account/timezone-settings.tsx index ac35f62c6..d3b189e12 100644 --- a/src/features/dashboard/account/timezone-settings.tsx +++ b/src/features/dashboard/account/timezone-settings.tsx @@ -1,6 +1,6 @@ 'use client' -import { useMemo, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' import { formatTimezoneLabel, getBrowserTimezone, @@ -46,18 +46,33 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { const [open, setOpen] = useState(false) const [isSaving, setIsSaving] = useState(false) - const browserTimezone = useMemo(() => getBrowserTimezone(), []) + 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( () => - getTimezones().map((option) => ({ + availableTimezones.map((option) => ({ value: option, label: formatTimezoneLabel(option), })), - [] + [availableTimezones] ) const timezoneLabel = useMemo(() => formatTimezoneLabel(timezone), [timezone]) const browserTimezoneLabel = useMemo( - () => formatTimezoneLabel(browserTimezone), + () => + browserTimezone ? formatTimezoneLabel(browserTimezone) : 'Detecting...', [browserTimezone] ) const isBrowserTimezoneSelected = timezone === browserTimezone @@ -137,7 +152,7 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { Browser timezone:{' '} {browserTimezoneLabel}

- {!isBrowserTimezoneSelected ? ( + {browserTimezone && !isBrowserTimezoneSelected ? (
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 b71db619e..3c979ab3f 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,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { useTimezone } from '@/features/dashboard/timezone' +import { formatDate, useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' import { LiveDot } from '@/ui/live' import { Button } from '@/ui/primitives/button' @@ -15,10 +15,7 @@ import { import { Separator } from '@/ui/primitives/separator' 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, @@ -99,19 +96,19 @@ export default function SandboxMonitoringTimeRangeControls({ }, [activePresetId, presets]) const rangeLabel = useMemo(() => { - const rangeLabelFormatter = new Intl.DateTimeFormat(undefined, { - ...SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, - timeZone: timezone, - }) const startDate = new Date(timeframe.start) const endDate = new Date(timeframe.end) if (!isValidDate(startDate) || !isValidDate(endDate)) { return '--' } - return `${rangeLabelFormatter.format(startDate)} - ${rangeLabelFormatter.format( - endDate - )}` + 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(() => { 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 ff22b506d..87b1fda19 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/formatters.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/formatters.ts @@ -1,18 +1,4 @@ -import type { Timezone } from '@/features/dashboard/timezone' -import { - SANDBOX_MONITORING_PERCENT_MAX, - SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, -} from './constants' - -export function formatHoverTimestamp( - timestampMs: number, - timezone: Timezone -): string { - return new Intl.DateTimeFormat(undefined, { - ...SANDBOX_MONITORING_TIME_LABEL_FORMAT_OPTIONS, - timeZone: timezone, - }).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/settings/keys/api-keys-table-row.tsx b/src/features/dashboard/settings/keys/api-keys-table-row.tsx index 2ff661c27..312314714 100644 --- a/src/features/dashboard/settings/keys/api-keys-table-row.tsx +++ b/src/features/dashboard/settings/keys/api-keys-table-row.tsx @@ -4,11 +4,7 @@ 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 { - formatDate, - formatZonedExactTimestamp, - useTimezone, -} from '@/features/dashboard/timezone' +import { formatDate, useTimezone } from '@/features/dashboard/timezone' import { defaultSuccessToast, useToast } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' import { E2BSquareBadge } from '@/ui/brand' @@ -83,7 +79,10 @@ export const ApiKeysTableRow = ({ apiKey, onDelete }: ApiKeysTableRowProps) => { {lastUsedLabel} - {formatZonedExactTimestamp(lastUsedAt, timezone)} + {formatDate(lastUsedAt, { + timezone, + format: 'exact-timestamp', + })} ) : ( diff --git a/src/features/dashboard/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index 77980e4a3..a1adf55da 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -199,6 +199,8 @@ const DATE_FORMAT_PRESETS = { date: 'MMM d, yyyy', // 9:05:12 AM time: 'h:mm:ss a', + // 09:05:12 + 'time-24h': 'HH:mm:ss', // 2026-06-08 09:05:12 EDT 'exact-timestamp': 'yyyy-MM-dd HH:mm:ss zzz', } as const @@ -401,11 +403,6 @@ const formatDateParts = ( } } -const formatZonedExactTimestamp = ( - value: string | number | Date, - timezone: Timezone -): string => formatInTimeZone(value, timezone, 'yyyy-MM-dd HH:mm:ss zzz') - // Returns a relative day label for a timestamp; e.g. today in NY -> "Today", prior day -> "Yesterday", else "Jun 9, 2026". const getRelativeDay = ( value: string | number | Date, @@ -432,7 +429,6 @@ export { formatTimezoneAbbreviation, formatZonedDateRange, formatZonedDateTimeInput, - formatZonedExactTimestamp, getRelativeDay, formatZonedTimeAxisLabel, getZonedDateParts, diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index 6ce74ef8a..69cc587f4 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -16,7 +16,6 @@ export { formatTimezoneAbbreviation, formatZonedDateRange, formatZonedDateTimeInput, - formatZonedExactTimestamp, formatZonedTimeAxisLabel, getRelativeDay, getZonedDateParts, diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index d071d6d0f..5f1730ccf 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -7,7 +7,6 @@ import { formatTimezoneAbbreviation, formatZonedDateRange, formatZonedDateTimeInput, - formatZonedExactTimestamp, formatZonedTimeAxisLabel, getRelativeDay, zonedDateTimePartsToUtcDate, @@ -203,14 +202,21 @@ describe('timezone date-time helpers', () => { }) ).toBe('9:05:09 AM') }) - }) - describe('formatZonedExactTimestamp', () => { + it('formats 24-hour time labels in the selected timezone', () => { + expect( + formatDate('2026-06-08T13:05:09.000Z', { + timezone: newYork, + format: 'time-24h', + }) + ).toBe('09:05:09') + }) + it('formats exact tooltip timestamps in the selected timezone', () => { - const result = formatZonedExactTimestamp( - '2026-06-08T13:00:00.000Z', - newYork - ) + const result = formatDate('2026-06-08T13:00:00.000Z', { + timezone: newYork, + format: 'exact-timestamp', + }) expect(result).toContain('2026-06-08') expect(result).toContain('09:00:00') From 4bfd3128d3a4521c7a203a4b6867e849923a9261 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 15:50:16 -0400 Subject: [PATCH 27/51] Centralize axis time formats --- .../monitoring/utils/chart-data-utils.ts | 12 +++++++----- src/features/dashboard/timezone/date-time.ts | 17 ++--------------- src/features/dashboard/timezone/index.ts | 1 - tests/unit/timezone-date-time.test.ts | 18 ++++++------------ 4 files changed, 15 insertions(+), 33 deletions(-) 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 7fecf72f5..99be68dad 100644 --- a/src/features/dashboard/sandbox/monitoring/utils/chart-data-utils.ts +++ b/src/features/dashboard/sandbox/monitoring/utils/chart-data-utils.ts @@ -1,7 +1,4 @@ -import { - formatZonedTimeAxisLabel, - type Timezone, -} from '@/features/dashboard/timezone' +import { formatDate, type Timezone } from '@/features/dashboard/timezone' import type { SandboxMetricsDataPoint } from '../types/sandbox-metrics-chart' export function toNumericValue(value: unknown): number { @@ -22,7 +19,12 @@ export function formatXAxisLabel( includeSeconds: boolean = false ): string { const timestamp = Number(value) - return formatZonedTimeAxisLabel(timestamp, timezone, includeSeconds) + return ( + formatDate(timestamp, { + timezone, + format: includeSeconds ? 'time-24h' : 'time-24h-no-seconds', + }) ?? '' + ) } export function findLivePoint( diff --git a/src/features/dashboard/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index a1adf55da..999101842 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -199,6 +199,8 @@ const DATE_FORMAT_PRESETS = { date: 'MMM d, yyyy', // 9:05:12 AM time: 'h:mm:ss a', + // 09:05 + 'time-24h-no-seconds': 'HH:mm', // 09:05:12 'time-24h': 'HH:mm:ss', // 2026-06-08 09:05:12 EDT @@ -235,20 +237,6 @@ const resolveDateFormatPreset = ( return DATE_FORMAT_PRESETS[preset] } -const formatZonedTimeAxisLabel = ( - timestamp: number, - timezone: Timezone, - includeSeconds = false -): string => { - if (Number.isNaN(timestamp)) return '' - - return formatInTimeZone( - timestamp, - timezone, - includeSeconds ? 'HH:mm:ss' : 'HH:mm' - ) -} - const createZonedTimeAxisLabelFormatter = ( timezone: Timezone, rangeMs: number @@ -430,7 +418,6 @@ export { formatZonedDateRange, formatZonedDateTimeInput, getRelativeDay, - formatZonedTimeAxisLabel, getZonedDateParts, getZonedDateTimeParts, shiftCalendarDays, diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index 69cc587f4..130677438 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -16,7 +16,6 @@ export { formatTimezoneAbbreviation, formatZonedDateRange, formatZonedDateTimeInput, - formatZonedTimeAxisLabel, getRelativeDay, getZonedDateParts, getZonedDateTimeParts, diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index 5f1730ccf..269c65528 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -7,7 +7,6 @@ import { formatTimezoneAbbreviation, formatZonedDateRange, formatZonedDateTimeInput, - formatZonedTimeAxisLabel, getRelativeDay, zonedDateTimePartsToUtcDate, zonedDateTimePartsToUtcTimestamp, @@ -159,17 +158,6 @@ describe('timezone date-time helpers', () => { }) }) - describe('formatZonedTimeAxisLabel', () => { - it('formats axis labels in the selected timezone', () => { - expect( - formatZonedTimeAxisLabel(Date.UTC(2026, 5, 8, 13, 5, 9), newYork) - ).toBe('09:05') - expect( - formatZonedTimeAxisLabel(Date.UTC(2026, 5, 8, 13, 5, 9), newYork, true) - ).toBe('09:05:09') - }) - }) - describe('createZonedTimeAxisLabelFormatter', () => { it('uses hour labels for short ranges', () => { const formatter = createZonedTimeAxisLabelFormatter( @@ -204,6 +192,12 @@ describe('timezone date-time helpers', () => { }) it('formats 24-hour time labels in the selected timezone', () => { + expect( + formatDate('2026-06-08T13:05:09.000Z', { + timezone: newYork, + format: 'time-24h-no-seconds', + }) + ).toBe('09:05') expect( formatDate('2026-06-08T13:05:09.000Z', { timezone: newYork, From b47b99123f590b1fe4e117125ffce7523cf2ee7e Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 16:00:03 -0400 Subject: [PATCH 28/51] Align date range formatting --- .../charts/concurrent-chart/hooks.ts | 5 +- src/features/dashboard/timezone/date-time.ts | 87 ++++++++++--------- src/features/dashboard/timezone/index.ts | 4 +- .../usage/usage-time-range-controls.tsx | 7 +- tests/unit/timezone-date-time.test.ts | 26 ++++-- 5 files changed, 70 insertions(+), 59 deletions(-) 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 d25d4813c..5303c8ad3 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/concurrent-chart/hooks.ts +++ b/src/features/dashboard/sandboxes/monitoring/charts/concurrent-chart/hooks.ts @@ -2,7 +2,7 @@ import { useMemo } from 'react' import type { TeamMetricsResponse } from '@/core/modules/sandboxes/models.client' import { formatDate, - formatZonedDateRange, + formatDateRange, useTimezone, } from '@/features/dashboard/timezone' import { formatNumber } from '@/lib/utils/formatting' @@ -78,7 +78,8 @@ export function useTimeRangeDisplay(timeframe: Timeframe) { // show timestamps for static mode or true custom ranges if (!timeframe.isLive || currentRange === 'custom') { - return formatZonedDateRange(timeframe.start, timeframe.end, timezone, { + return formatDateRange(timeframe.start, timeframe.end, { + timezone, includeTime: true, includeTimezone: true, }) diff --git a/src/features/dashboard/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index 999101842..18bea072a 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -101,11 +101,6 @@ const shiftCalendarDays = ( } } -interface ZonedDateRangeFormatOptions { - includeTime?: boolean - includeTimezone?: boolean -} - const pad = (value: number): string => String(value).padStart(2, '0') const formatZonedDateTimeInput = ( @@ -158,47 +153,14 @@ const formatTimezoneAbbreviation = ( return abbreviation ?? timezone } -const formatZonedDateRange = ( - start: string | number | Date, - end: string | number | Date, - timezone: Timezone, - { - includeTime = false, - includeTimezone = true, - }: ZonedDateRangeFormatOptions = {} -): string => { - const baseOptions: Intl.DateTimeFormatOptions = { - timeZone: timezone, - year: 'numeric', - month: 'short', - day: 'numeric', - } - - if (includeTime) { - baseOptions.hour = '2-digit' - baseOptions.minute = '2-digit' - baseOptions.second = '2-digit' - } - - const endOptions: Intl.DateTimeFormatOptions = { - ...baseOptions, - timeZoneName: includeTimezone ? 'short' : undefined, - } - - const startFormatter = new Intl.DateTimeFormat('en-US', baseOptions) - const endFormatter = new Intl.DateTimeFormat('en-US', endOptions) - - return `${startFormatter.format(new Date(start))} - ${endFormatter.format( - new Date(end) - )}` -} - // Static date-fns format strings for known display presets. const DATE_FORMAT_PRESETS = { // Jun 8, 2026 date: 'MMM d, yyyy', // 9:05:12 AM time: 'h:mm:ss a', + // 09:05:12 AM + 'time-padded-hour': 'hh:mm:ss a', // 09:05 'time-24h-no-seconds': 'HH:mm', // 09:05:12 @@ -284,6 +246,47 @@ const formatDate = ( ) } +interface FormatDateRangeOptions { + timezone: Timezone + includeTime?: boolean + includeTimezone?: boolean +} + +const formatDateRange = ( + start: string | number | Date, + end: string | number | Date, + { + timezone, + includeTime = false, + includeTimezone = true, + }: FormatDateRangeOptions +): string => { + const formatSide = ( + value: string | number | Date, + withTimezone: boolean + ): string => { + const date = formatDate(value, { timezone, format: 'date' }) ?? '' + + if (includeTime) { + const time = + formatDate(value, { timezone, format: 'time-padded-hour' }) ?? '' + const base = `${date} at ${time}` + + if (withTimezone) + return `${base} ${formatTimezoneAbbreviation(value, timezone)}` + + return base + } + + if (withTimezone) + return `${date} ${formatTimezoneAbbreviation(value, timezone)}` + + return date + } + + return `${formatSide(start, false)} - ${formatSide(end, includeTimezone)}` +} + interface DateTimeParts { datePart: string timePart: string @@ -414,8 +417,8 @@ export { createZonedTimeAxisLabelFormatter, formatDate, formatDateParts, + formatDateRange, formatTimezoneAbbreviation, - formatZonedDateRange, formatZonedDateTimeInput, getRelativeDay, getZonedDateParts, @@ -431,7 +434,7 @@ export type { DateTimeParts, FormatDateOptions, FormatDatePartsOptions, + FormatDateRangeOptions, ZonedDateParts, - ZonedDateRangeFormatOptions, ZonedDateTimeParts, } diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index 130677438..e348c0d76 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -5,16 +5,16 @@ export type { DateTimeParts, FormatDateOptions, FormatDatePartsOptions, + FormatDateRangeOptions, ZonedDateParts, - ZonedDateRangeFormatOptions, ZonedDateTimeParts, } from './date-time' export { createZonedTimeAxisLabelFormatter, formatDate, formatDateParts, + formatDateRange, formatTimezoneAbbreviation, - formatZonedDateRange, formatZonedDateTimeInput, getRelativeDay, getZonedDateParts, diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index b8506b9fd..4f5489e48 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -1,10 +1,7 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { - formatZonedDateRange, - useTimezone, -} from '@/features/dashboard/timezone' +import { formatDateRange, useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' import { findMatchingPreset } from '@/lib/utils/time-range' import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe' @@ -64,7 +61,7 @@ export function UsageTimeRangeControls({ ) const rangeLabel = useMemo(() => { - return formatZonedDateRange(timeframe.start, timeframe.end, timezone) + return formatDateRange(timeframe.start, timeframe.end, { timezone }) }, [timeframe.start, timeframe.end, timezone]) const rangeCopyValue = useMemo( diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index 269c65528..9017b51df 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -4,8 +4,8 @@ import { createZonedTimeAxisLabelFormatter, formatDate, formatDateParts, + formatDateRange, formatTimezoneAbbreviation, - formatZonedDateRange, formatZonedDateTimeInput, getRelativeDay, zonedDateTimePartsToUtcDate, @@ -131,17 +131,27 @@ describe('timezone date-time helpers', () => { }) }) - describe('formatZonedDateRange', () => { - it('formats a range in the selected timezone', () => { - const result = formatZonedDateRange( + describe('formatDateRange', () => { + it('formats a date-only range in the selected timezone', () => { + const result = formatDateRange( '2026-06-08T13:00:00.000Z', '2026-06-09T13:00:00.000Z', - newYork + { timezone: newYork } ) - expect(result).toContain('Jun 8, 2026') - expect(result).toContain('Jun 9, 2026') - expect(result).toContain('EDT') + expect(result).toBe('Jun 8, 2026 - Jun 9, 2026 EDT') + }) + + it('formats a timestamp range in the selected timezone', () => { + const result = formatDateRange( + '2026-06-08T13:00:00.000Z', + '2026-06-09T13:00:00.000Z', + { timezone: newYork, includeTime: true, includeTimezone: true } + ) + + expect(result).toBe( + 'Jun 8, 2026 at 09:00:00 AM - Jun 9, 2026 at 09:00:00 AM EDT' + ) }) }) From 993821fd37d4911efa29741daf67d03237a662b1 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 16:02:27 -0400 Subject: [PATCH 29/51] Clarify date range presets --- .../charts/concurrent-chart/hooks.ts | 3 +- src/features/dashboard/timezone/date-time.ts | 42 ++++++------------- tests/unit/timezone-date-time.test.ts | 2 +- 3 files changed, 14 insertions(+), 33 deletions(-) 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 5303c8ad3..5cff10a92 100644 --- a/src/features/dashboard/sandboxes/monitoring/charts/concurrent-chart/hooks.ts +++ b/src/features/dashboard/sandboxes/monitoring/charts/concurrent-chart/hooks.ts @@ -80,8 +80,7 @@ export function useTimeRangeDisplay(timeframe: Timeframe) { if (!timeframe.isLive || currentRange === 'custom') { return formatDateRange(timeframe.start, timeframe.end, { timezone, - includeTime: true, - includeTimezone: true, + format: 'date-time-padded-hour', }) } diff --git a/src/features/dashboard/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index 18bea072a..927e4c2f9 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -159,8 +159,8 @@ const DATE_FORMAT_PRESETS = { date: 'MMM d, yyyy', // 9:05:12 AM time: 'h:mm:ss a', - // 09:05:12 AM - 'time-padded-hour': 'hh:mm:ss a', + // Jun 8, 2026 at 09:05:12 AM + 'date-time-padded-hour': "MMM d, yyyy 'at' hh:mm:ss a", // 09:05 'time-24h-no-seconds': 'HH:mm', // 09:05:12 @@ -248,43 +248,25 @@ const formatDate = ( interface FormatDateRangeOptions { timezone: Timezone - includeTime?: boolean - includeTimezone?: boolean + format?: DateFormatPreset } +const TIMEZONE_INCLUSIVE_DATE_FORMAT_PRESETS: ReadonlySet = + new Set(['compact-timestamp', 'exact-timestamp']) + const formatDateRange = ( start: string | number | Date, end: string | number | Date, - { - timezone, - includeTime = false, - includeTimezone = true, - }: FormatDateRangeOptions + { timezone, format = 'date' }: FormatDateRangeOptions ): string => { - const formatSide = ( - value: string | number | Date, - withTimezone: boolean - ): string => { - const date = formatDate(value, { timezone, format: 'date' }) ?? '' - - if (includeTime) { - const time = - formatDate(value, { timezone, format: 'time-padded-hour' }) ?? '' - const base = `${date} at ${time}` - - if (withTimezone) - return `${base} ${formatTimezoneAbbreviation(value, timezone)}` - - return base - } - - if (withTimezone) - return `${date} ${formatTimezoneAbbreviation(value, timezone)}` + const startLabel = formatDate(start, { timezone, format }) ?? '' + const endLabel = formatDate(end, { timezone, format }) ?? '' - return date + if (TIMEZONE_INCLUSIVE_DATE_FORMAT_PRESETS.has(format)) { + return `${startLabel} - ${endLabel}` } - return `${formatSide(start, false)} - ${formatSide(end, includeTimezone)}` + return `${startLabel} - ${endLabel} ${formatTimezoneAbbreviation(end, timezone)}` } interface DateTimeParts { diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index 9017b51df..6a1359e10 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -146,7 +146,7 @@ describe('timezone date-time helpers', () => { const result = formatDateRange( '2026-06-08T13:00:00.000Z', '2026-06-09T13:00:00.000Z', - { timezone: newYork, includeTime: true, includeTimezone: true } + { timezone: newYork, format: 'date-time-padded-hour' } ) expect(result).toBe( From 72ea104abd2b3260810e8bbaae36d77c9bf14614 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 16:12:02 -0400 Subject: [PATCH 30/51] Localize axis label strategy --- .../charts/team-metrics-chart/index.tsx | 6 ++--- .../charts/team-metrics-chart/utils.ts | 25 +++++++++++++++++++ src/features/dashboard/timezone/date-time.ts | 19 +++----------- src/features/dashboard/timezone/index.ts | 1 - tests/unit/chart-utils.test.ts | 19 +++++++++++++- tests/unit/timezone-date-time.test.ts | 12 --------- 6 files changed, 49 insertions(+), 33 deletions(-) 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 133ad22ce..ba804a7a1 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 @@ -20,10 +20,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 { - createZonedTimeAxisLabelFormatter, - useTimezone, -} from '@/features/dashboard/timezone' +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' @@ -34,6 +31,7 @@ import { createLiveIndicators, createSplitLineInterval, createYAxisLabelFormatter, + createZonedTimeAxisLabelFormatter, hasLiveData, transformMetrics, } from './utils' 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..5e6b99643 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 { + type DateFormatPreset, + formatDate, + type Timezone, +} from '@/features/dashboard/timezone' import { formatAxisNumber } from '@/lib/utils/formatting' import type { TeamMetricDataPoint } from './types' +const resolveTimeAxisLabelFormat = (rangeMs: number): DateFormatPreset => { + 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 createZonedTimeAxisLabelFormatter = ( + 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/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index 927e4c2f9..ea1704265 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -161,6 +161,10 @@ const DATE_FORMAT_PRESETS = { time: 'h:mm:ss a', // Jun 8, 2026 at 09:05:12 AM 'date-time-padded-hour': "MMM d, yyyy 'at' hh:mm:ss a", + // Jun 8 + 'month-day': 'MMM d', + // 2026 + year: 'yyyy', // 09:05 'time-24h-no-seconds': 'HH:mm', // 09:05:12 @@ -199,20 +203,6 @@ const resolveDateFormatPreset = ( return DATE_FORMAT_PRESETS[preset] } -const createZonedTimeAxisLabelFormatter = ( - timezone: Timezone, - rangeMs: number -): ((value: number) => string) => { - const format = - rangeMs > 365 * 24 * 60 * 60 * 1000 - ? 'yyyy' - : rangeMs > 2 * 24 * 60 * 60 * 1000 - ? 'MMM d' - : 'HH:mm' - - return (value: number) => formatInTimeZone(value, timezone, format) -} - interface FormatDateOptions { timezone: Timezone format?: DateFormatPreset @@ -396,7 +386,6 @@ const getRelativeDay = ( } export { - createZonedTimeAxisLabelFormatter, formatDate, formatDateParts, formatDateRange, diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index e348c0d76..e87a27311 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -10,7 +10,6 @@ export type { ZonedDateTimeParts, } from './date-time' export { - createZonedTimeAxisLabelFormatter, formatDate, formatDateParts, formatDateRange, diff --git a/tests/unit/chart-utils.test.ts b/tests/unit/chart-utils.test.ts index 63dd68cc5..8b14891d5 100644 --- a/tests/unit/chart-utils.test.ts +++ b/tests/unit/chart-utils.test.ts @@ -1,13 +1,30 @@ import { describe, expect, it } from 'vitest' import type { ClientTeamMetric } from '@/core/modules/sandboxes/models.client' -import { transformMetrics } from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils' +import { + createZonedTimeAxisLabelFormatter, + transformMetrics, +} from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils' import { getDeliveryCountSeriesData, getResponseTimeSeriesData, } from '@/features/dashboard/settings/webhooks/detail/chart-utils' import { calculateAxisMax } from '@/lib/utils/chart' +import { requireTimezone } from './helpers/timezone' describe('team-metrics-chart-utils', () => { + const newYork = requireTimezone('America/New_York') + + describe('createZonedTimeAxisLabelFormatter', () => { + it('uses hour labels for short ranges', () => { + const formatter = createZonedTimeAxisLabelFormatter( + newYork, + 60 * 60 * 1000 + ) + + expect(formatter(Date.UTC(2026, 5, 8, 13, 0, 0))).toBe('09:00') + }) + }) + describe('calculateYAxisMax', () => { it('should round to nice numbers based on data', () => { const data = [ diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index 6a1359e10..996b3bf8b 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -1,7 +1,6 @@ import { startOfDay } from 'date-fns' import { describe, expect, it, vi } from 'vitest' import { - createZonedTimeAxisLabelFormatter, formatDate, formatDateParts, formatDateRange, @@ -168,17 +167,6 @@ describe('timezone date-time helpers', () => { }) }) - describe('createZonedTimeAxisLabelFormatter', () => { - it('uses hour labels for short ranges', () => { - const formatter = createZonedTimeAxisLabelFormatter( - newYork, - 60 * 60 * 1000 - ) - - expect(formatter(Date.UTC(2026, 5, 8, 13, 0, 0))).toBe('09:00') - }) - }) - describe('formatDate', () => { it('formats date-only labels in the selected timezone', () => { expect( From 7db5c29fafa77ce3455b7d9d785d59cfce828176 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 16:14:22 -0400 Subject: [PATCH 31/51] Rename axis label formatter --- .../monitoring/charts/team-metrics-chart/index.tsx | 7 ++----- .../monitoring/charts/team-metrics-chart/utils.ts | 2 +- tests/unit/chart-utils.test.ts | 9 +++------ 3 files changed, 6 insertions(+), 12 deletions(-) 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 ba804a7a1..93eccdf1b 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 @@ -30,8 +30,8 @@ import { createLimitLine, createLiveIndicators, createSplitLineInterval, + createTimeAxisLabelFormatter, createYAxisLabelFormatter, - createZonedTimeAxisLabelFormatter, hasLiveData, transformMetrics, } from './utils' @@ -180,10 +180,7 @@ function TeamMetricsChart({ const xAxisLabelFormatter = useMemo( () => - createZonedTimeAxisLabelFormatter( - timezone, - timeframe.end - timeframe.start - ), + createTimeAxisLabelFormatter(timezone, timeframe.end - timeframe.start), [timeframe.end, timeframe.start, timezone] ) 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 5e6b99643..83cf07bbf 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 @@ -18,7 +18,7 @@ const resolveTimeAxisLabelFormat = (rangeMs: number): DateFormatPreset => { * 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 createZonedTimeAxisLabelFormatter = ( +export const createTimeAxisLabelFormatter = ( timezone: Timezone, rangeMs: number ): ((value: number) => string) => { diff --git a/tests/unit/chart-utils.test.ts b/tests/unit/chart-utils.test.ts index 8b14891d5..3ae98a231 100644 --- a/tests/unit/chart-utils.test.ts +++ b/tests/unit/chart-utils.test.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { ClientTeamMetric } from '@/core/modules/sandboxes/models.client' import { - createZonedTimeAxisLabelFormatter, + createTimeAxisLabelFormatter, transformMetrics, } from '@/features/dashboard/sandboxes/monitoring/charts/team-metrics-chart/utils' import { @@ -14,12 +14,9 @@ import { requireTimezone } from './helpers/timezone' describe('team-metrics-chart-utils', () => { const newYork = requireTimezone('America/New_York') - describe('createZonedTimeAxisLabelFormatter', () => { + describe('createTimeAxisLabelFormatter', () => { it('uses hour labels for short ranges', () => { - const formatter = createZonedTimeAxisLabelFormatter( - newYork, - 60 * 60 * 1000 - ) + const formatter = createTimeAxisLabelFormatter(newYork, 60 * 60 * 1000) expect(formatter(Date.UTC(2026, 5, 8, 13, 0, 0))).toBe('09:00') }) From 4a60f0dccd93169bea4c50b47669bd7194ff29ab Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 16:16:54 -0400 Subject: [PATCH 32/51] Rename date format type --- .../monitoring/charts/team-metrics-chart/utils.ts | 4 ++-- src/features/dashboard/timezone/date-time.ts | 10 +++++----- src/features/dashboard/timezone/index.ts | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) 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 83cf07bbf..815b2fb6a 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,13 +1,13 @@ import type { ClientTeamMetric } from '@/core/modules/sandboxes/models.client' import { - type DateFormatPreset, + type DateFormat, formatDate, type Timezone, } from '@/features/dashboard/timezone' import { formatAxisNumber } from '@/lib/utils/formatting' import type { TeamMetricDataPoint } from './types' -const resolveTimeAxisLabelFormat = (rangeMs: number): DateFormatPreset => { +const resolveTimeAxisLabelFormat = (rangeMs: number): DateFormat => { if (rangeMs > 365 * 24 * 60 * 60 * 1000) return 'year' if (rangeMs > 2 * 24 * 60 * 60 * 1000) return 'month-day' diff --git a/src/features/dashboard/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index ea1704265..009435c4b 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -178,7 +178,7 @@ type StaticDateFormatPreset = keyof typeof DATE_FORMAT_PRESETS // Special presets resolved outside DATE_FORMAT_PRESETS: // compact-timestamp: Jun 8, 9:05:12 AM EDT (current year) / 2025 Jun 8, 9:05:12 AM EST // time-with-centiseconds: 09:05:09.87 AM -type DateFormatPreset = +type DateFormat = | StaticDateFormatPreset | 'compact-timestamp' | 'time-with-centiseconds' @@ -205,7 +205,7 @@ const resolveDateFormatPreset = ( interface FormatDateOptions { timezone: Timezone - format?: DateFormatPreset + format?: DateFormat } const formatTimeWithCentiseconds = ( @@ -238,10 +238,10 @@ const formatDate = ( interface FormatDateRangeOptions { timezone: Timezone - format?: DateFormatPreset + format?: DateFormat } -const TIMEZONE_INCLUSIVE_DATE_FORMAT_PRESETS: ReadonlySet = +const TIMEZONE_INCLUSIVE_DATE_FORMAT_PRESETS: ReadonlySet = new Set(['compact-timestamp', 'exact-timestamp']) const formatDateRange = ( @@ -400,7 +400,7 @@ export { zonedInstantToCalendarDate, } export type { - DateFormatPreset, + DateFormat, DatePartsFormatPreset, DateTimeParts, FormatDateOptions, diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index e87a27311..362a4bab6 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -1,6 +1,6 @@ export { TimezoneProvider, useTimezone } from './context' export type { - DateFormatPreset, + DateFormat, DatePartsFormatPreset, DateTimeParts, FormatDateOptions, From eb041b0a5bf61647c79ae60bbf8fb8693ce383de Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 16:46:28 -0400 Subject: [PATCH 33/51] Tighten timezone setter type --- src/features/dashboard/timezone/context.tsx | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/features/dashboard/timezone/context.tsx b/src/features/dashboard/timezone/context.tsx index 95551e79d..497cb7319 100644 --- a/src/features/dashboard/timezone/context.tsx +++ b/src/features/dashboard/timezone/context.tsx @@ -16,7 +16,7 @@ const DEFAULT_TIMEZONE = TimezoneSchema.parse('UTC') interface TimezoneContextValue { timezone: Timezone - setTimezone: (timezone: string) => Promise + setTimezone: (timezone: Timezone) => Promise } interface TimezoneProviderProps { @@ -63,14 +63,11 @@ export const TimezoneProvider = ({ }, [parsedInitialTimezone]) const setTimezone = useCallback( - async (nextTimezone: string) => { - const parsedTimezone = parseTimezone(nextTimezone) - if (!parsedTimezone) return false - + async (nextTimezone: Timezone) => { const previousTimezone = timezone - setTimezoneState(parsedTimezone) + setTimezoneState(nextTimezone) - const didPersist = await persistTimezone(parsedTimezone) + const didPersist = await persistTimezone(nextTimezone) if (!didPersist) { setTimezoneState(previousTimezone) return false From 6f7db68cc90173b6780a0a6f859b386a8283682d Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 16:49:23 -0400 Subject: [PATCH 34/51] Trim timezone type exports --- src/features/dashboard/timezone/date-time.ts | 11 +---------- src/features/dashboard/timezone/index.ts | 11 +---------- 2 files changed, 2 insertions(+), 20 deletions(-) diff --git a/src/features/dashboard/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index 009435c4b..5e3f89961 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -399,13 +399,4 @@ export { zonedDateTimePartsToUtcTimestamp, zonedInstantToCalendarDate, } -export type { - DateFormat, - DatePartsFormatPreset, - DateTimeParts, - FormatDateOptions, - FormatDatePartsOptions, - FormatDateRangeOptions, - ZonedDateParts, - ZonedDateTimeParts, -} +export type { DateFormat, ZonedDateTimeParts } diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index 362a4bab6..36165d86f 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -1,14 +1,5 @@ export { TimezoneProvider, useTimezone } from './context' -export type { - DateFormat, - DatePartsFormatPreset, - DateTimeParts, - FormatDateOptions, - FormatDatePartsOptions, - FormatDateRangeOptions, - ZonedDateParts, - ZonedDateTimeParts, -} from './date-time' +export type { DateFormat, ZonedDateTimeParts } from './date-time' export { formatDate, formatDateParts, From 48593f2bab4ffe6862d182799d73b1a35293b612 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Fri, 12 Jun 2026 16:53:47 -0400 Subject: [PATCH 35/51] Remove zoned helper prefixes --- .../sandboxes/monitoring/time-picker/utils.ts | 4 +- src/features/dashboard/timezone/date-time.ts | 46 +++++++++---------- src/features/dashboard/timezone/index.ts | 14 +++--- src/features/dashboard/usage/constants.ts | 26 +++++------ src/features/dashboard/usage/display-utils.ts | 10 ++-- .../dashboard/usage/sampling-utils.ts | 26 +++++------ src/ui/time-range-picker.logic.ts | 4 +- src/ui/time-range-picker.tsx | 12 ++--- tests/unit/time-range-picker-logic.test.ts | 4 +- tests/unit/timezone-date-time.test.ts | 28 +++++------ tests/unit/usage-time-range-presets.test.ts | 10 ++-- 11 files changed, 92 insertions(+), 92 deletions(-) diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts index 2faa24fa8..7723161ef 100644 --- a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts +++ b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts @@ -1,6 +1,6 @@ import { calculateStepForDuration } from '@/features/dashboard/sandboxes/monitoring/utils' import { - formatZonedDateTimeInput, + formatDateTimeInput, type Timezone, } from '@/features/dashboard/timezone' import type { TimeframeState } from '@/lib/utils/timeframe' @@ -14,7 +14,7 @@ const formatZonedDateTimeString = ( timestamp: number, timezone: Timezone ): string => { - const parts = formatZonedDateTimeInput(timestamp, timezone) + const parts = formatDateTimeInput(timestamp, timezone) return `${parts.date} ${parts.time}` } diff --git a/src/features/dashboard/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index 5e3f89961..a0b3456ec 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -1,13 +1,13 @@ import { formatInTimeZone, fromZonedTime } from 'date-fns-tz' import type { Timezone } from './schema' -interface ZonedDateParts { +interface CalendarDateParts { year: number month: number day: number } -interface ZonedDateTimeParts extends ZonedDateParts { +interface CalendarDateTimeParts extends CalendarDateParts { hours: number minutes: number seconds: number @@ -37,10 +37,10 @@ const parseRequiredInt = ( return parsed } -const getZonedDateParts = ( +const getDateParts = ( value: string | number | Date, timezone: Timezone -): ZonedDateParts => { +): CalendarDateParts => { const parts = parseZonedFormatParts( new Intl.DateTimeFormat('en-US', { timeZone: timezone, @@ -58,10 +58,10 @@ const getZonedDateParts = ( } } -const getZonedDateTimeParts = ( +const getDateTimeParts = ( value: string | number | Date, timezone: Timezone -): ZonedDateTimeParts => { +): CalendarDateTimeParts => { const parts = parseZonedFormatParts( new Intl.DateTimeFormat('en-US', { timeZone: timezone, @@ -88,9 +88,9 @@ const getZonedDateTimeParts = ( // Shifts a calendar date without applying local timezone rules; e.g. 2026-06-10 + -89 -> 2026-03-13. const shiftCalendarDays = ( - parts: ZonedDateParts, + parts: CalendarDateParts, days: number -): ZonedDateParts => { +): CalendarDateParts => { const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)) date.setUTCDate(date.getUTCDate() + days) @@ -103,7 +103,7 @@ const shiftCalendarDays = ( const pad = (value: number): string => String(value).padStart(2, '0') -const formatZonedDateTimeInput = ( +const formatDateTimeInput = ( value: string | number | Date, timezone: Timezone ): { date: string; time: string } => ({ @@ -111,11 +111,11 @@ const formatZonedDateTimeInput = ( time: formatInTimeZone(value, timezone, 'HH:mm:ss'), }) -const zonedInstantToCalendarDate = ( +const instantToCalendarDate = ( value: string | number | Date, timezone: Timezone ): Date => { - const { date } = formatZonedDateTimeInput(value, timezone) + const { date } = formatDateTimeInput(value, timezone) const [year = 0, month = 0, day = 0] = date.split('/').map(Number) return new Date(year, month - 1, day) @@ -123,8 +123,8 @@ const zonedInstantToCalendarDate = ( // Converts timezone wall-clock parts to UTC; e.g. 2026-06-08 09:00:00 in America/New_York -> 2026-06-08T13:00:00.000Z. // DST gaps/overlaps are resolved by date-fns-tz consistently instead of rejected. -const zonedDateTimePartsToUtcDate = ( - parts: ZonedDateTimeParts, +const dateTimePartsToUtcDate = ( + parts: CalendarDateTimeParts, timezone: Timezone ): Date => { const wallClockValue = `${parts.year}-${pad(parts.month)}-${pad( @@ -134,10 +134,10 @@ const zonedDateTimePartsToUtcDate = ( return fromZonedTime(wallClockValue, timezone) } -const zonedDateTimePartsToUtcTimestamp = ( - parts: ZonedDateTimeParts, +const dateTimePartsToUtcTimestamp = ( + parts: CalendarDateTimeParts, timezone: Timezone -): number => zonedDateTimePartsToUtcDate(parts, timezone).getTime() +): number => dateTimePartsToUtcDate(parts, timezone).getTime() const formatTimezoneAbbreviation = ( value: string | number | Date, @@ -390,13 +390,13 @@ export { formatDateParts, formatDateRange, formatTimezoneAbbreviation, - formatZonedDateTimeInput, + formatDateTimeInput, getRelativeDay, - getZonedDateParts, - getZonedDateTimeParts, + getDateParts, + getDateTimeParts, shiftCalendarDays, - zonedDateTimePartsToUtcDate, - zonedDateTimePartsToUtcTimestamp, - zonedInstantToCalendarDate, + dateTimePartsToUtcDate, + dateTimePartsToUtcTimestamp, + instantToCalendarDate, } -export type { DateFormat, ZonedDateTimeParts } +export type { DateFormat, CalendarDateTimeParts } diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index 36165d86f..a25f5e693 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -1,18 +1,18 @@ export { TimezoneProvider, useTimezone } from './context' -export type { DateFormat, ZonedDateTimeParts } from './date-time' +export type { DateFormat, CalendarDateTimeParts } from './date-time' export { formatDate, formatDateParts, formatDateRange, formatTimezoneAbbreviation, - formatZonedDateTimeInput, + formatDateTimeInput, getRelativeDay, - getZonedDateParts, - getZonedDateTimeParts, + getDateParts, + getDateTimeParts, shiftCalendarDays, - zonedDateTimePartsToUtcDate, - zonedDateTimePartsToUtcTimestamp, - zonedInstantToCalendarDate, + dateTimePartsToUtcDate, + dateTimePartsToUtcTimestamp, + instantToCalendarDate, } from './date-time' export type { Timezone } from './schema' export { TimezoneSchema } from './schema' diff --git a/src/features/dashboard/usage/constants.ts b/src/features/dashboard/usage/constants.ts index 6de73792f..19eb0b444 100644 --- a/src/features/dashboard/usage/constants.ts +++ b/src/features/dashboard/usage/constants.ts @@ -1,8 +1,8 @@ import { - getZonedDateParts, + getDateParts, shiftCalendarDays, type Timezone, - zonedDateTimePartsToUtcTimestamp, + dateTimePartsToUtcTimestamp, } from '@/features/dashboard/timezone' import { formatAxisNumber } from '@/lib/utils/formatting' import type { TimeRangePreset } from '@/ui/time-range-presets' @@ -23,7 +23,7 @@ export const INITIAL_TIMEFRAME_FALLBACK_RANGE_MS = 30 * 24 * 60 * 60 * 1000 export const HOURLY_SAMPLING_THRESHOLD_DAYS = 3 export const WEEKLY_SAMPLING_THRESHOLD_DAYS = 60 -type CalendarDateParts = ReturnType +type CalendarDateParts = ReturnType const shiftToMonthStart = ( parts: CalendarDateParts, @@ -53,7 +53,7 @@ const getZonedDayBoundaryTimestamps = ( endParts: CalendarDateParts, timezone: Timezone ): { start: number; end: number } => ({ - start: zonedDateTimePartsToUtcTimestamp( + start: dateTimePartsToUtcTimestamp( { ...startParts, hours: 0, @@ -63,7 +63,7 @@ const getZonedDayBoundaryTimestamps = ( timezone ), end: - zonedDateTimePartsToUtcTimestamp( + dateTimePartsToUtcTimestamp( { ...endParts, hours: 23, @@ -80,7 +80,7 @@ const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ label: 'Last 7 days', shortcut: '7D', getValue: () => { - const today = getZonedDateParts(new Date(), timezone) + const today = getDateParts(new Date(), timezone) return getZonedDayBoundaryTimestamps( shiftCalendarDays(today, -6), today, @@ -93,7 +93,7 @@ const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ label: 'Last 14 days', shortcut: '14D', getValue: () => { - const today = getZonedDateParts(new Date(), timezone) + const today = getDateParts(new Date(), timezone) return getZonedDayBoundaryTimestamps( shiftCalendarDays(today, -13), today, @@ -106,7 +106,7 @@ const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ label: 'Last 30 days', shortcut: '30D', getValue: () => { - const today = getZonedDateParts(new Date(), timezone) + const today = getDateParts(new Date(), timezone) return getZonedDayBoundaryTimestamps( shiftCalendarDays(today, -29), today, @@ -119,7 +119,7 @@ const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ label: 'Last 90 days', shortcut: '90D', getValue: () => { - const today = getZonedDateParts(new Date(), timezone) + const today = getDateParts(new Date(), timezone) return getZonedDayBoundaryTimestamps( shiftCalendarDays(today, -89), today, @@ -131,7 +131,7 @@ const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ id: 'this-month', label: 'This month', getValue: () => { - const today = getZonedDateParts(new Date(), timezone) + const today = getDateParts(new Date(), timezone) const start = { year: today.year, month: today.month, day: 1 } return getZonedDayBoundaryTimestamps( start, @@ -144,7 +144,7 @@ const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ id: 'last-month', label: 'Last month', getValue: () => { - const today = getZonedDateParts(new Date(), timezone) + const today = getDateParts(new Date(), timezone) const start = shiftToMonthStart(today, -1) return getZonedDayBoundaryTimestamps( start, @@ -157,7 +157,7 @@ const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ id: 'this-year', label: 'This year', getValue: () => { - const today = getZonedDateParts(new Date(), timezone) + const today = getDateParts(new Date(), timezone) return getZonedDayBoundaryTimestamps( { year: today.year, month: 1, day: 1 }, { year: today.year, month: 12, day: 31 }, @@ -169,7 +169,7 @@ const getUsageTimeRangePresets = (timezone: Timezone): TimeRangePreset[] => [ id: 'last-year', label: 'Last year', getValue: () => { - const today = getZonedDateParts(new Date(), timezone) + const today = getDateParts(new Date(), timezone) const year = today.year - 1 return getZonedDayBoundaryTimestamps( { year, month: 1, day: 1 }, diff --git a/src/features/dashboard/usage/display-utils.ts b/src/features/dashboard/usage/display-utils.ts index ef27215dd..37ca7220d 100644 --- a/src/features/dashboard/usage/display-utils.ts +++ b/src/features/dashboard/usage/display-utils.ts @@ -1,4 +1,4 @@ -import { getZonedDateParts, type Timezone } from '@/features/dashboard/timezone' +import { getDateParts, type Timezone } from '@/features/dashboard/timezone' import { formatCurrency, formatNumber } from '@/lib/utils/formatting' import { determineSamplingMode, @@ -13,8 +13,8 @@ import type { } from './types' const isThisYearInTimezone = (timestamp: number, timezone: Timezone): boolean => - getZonedDateParts(timestamp, timezone).year === - getZonedDateParts(new Date(), timezone).year + getDateParts(timestamp, timezone).year === + getDateParts(new Date(), timezone).year const formatZonedDay = (timestamp: number, timezone: Timezone): string => new Intl.DateTimeFormat('en-US', { @@ -44,8 +44,8 @@ const formatZonedWeekRange = ( endTimestamp: number, timezone: Timezone ): string => { - const startParts = getZonedDateParts(startTimestamp, timezone) - const endParts = getZonedDateParts(endTimestamp, timezone) + const startParts = getDateParts(startTimestamp, timezone) + const endParts = getDateParts(endTimestamp, timezone) const sameYear = startParts.year === endParts.year const sameMonth = sameYear && startParts.month === endParts.month diff --git a/src/features/dashboard/usage/sampling-utils.ts b/src/features/dashboard/usage/sampling-utils.ts index 43891b2d9..371065a68 100644 --- a/src/features/dashboard/usage/sampling-utils.ts +++ b/src/features/dashboard/usage/sampling-utils.ts @@ -1,10 +1,10 @@ import type { UsageResponse } from '@/core/modules/billing/models' import { - getZonedDateTimeParts, + getDateTimeParts, shiftCalendarDays, type Timezone, - type ZonedDateTimeParts, - zonedDateTimePartsToUtcTimestamp, + type CalendarDateTimeParts, + dateTimePartsToUtcTimestamp, } from '@/features/dashboard/timezone' import { HOURLY_SAMPLING_THRESHOLD_DAYS, @@ -13,8 +13,8 @@ import { import type { SampledDataPoint, SamplingMode, Timeframe } from './types' const getIsoWeekStartParts = ( - parts: Pick -): Pick => { + parts: Pick +): Pick => { const date = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)) const daysSinceMonday = (date.getUTCDay() + 6) % 7 return shiftCalendarDays(parts, -daysSinceMonday) @@ -62,17 +62,17 @@ export function normalizeToStartOfSamplingPeriod( mode: SamplingMode, timezone: Timezone ): number { - const parts = getZonedDateTimeParts(timestamp, timezone) + const parts = getDateTimeParts(timestamp, timezone) switch (mode) { case 'hourly': - return zonedDateTimePartsToUtcTimestamp( + return dateTimePartsToUtcTimestamp( { ...parts, minutes: 0, seconds: 0 }, timezone ) case 'daily': - return zonedDateTimePartsToUtcTimestamp( + return dateTimePartsToUtcTimestamp( { ...parts, hours: 0, minutes: 0, seconds: 0 }, timezone ) @@ -80,7 +80,7 @@ export function normalizeToStartOfSamplingPeriod( case 'weekly': { const weekStart = getIsoWeekStartParts(parts) - return zonedDateTimePartsToUtcTimestamp( + return dateTimePartsToUtcTimestamp( { ...weekStart, hours: 0, @@ -98,12 +98,12 @@ export function normalizeToEndOfSamplingPeriod( mode: SamplingMode, timezone: Timezone ): number { - const parts = getZonedDateTimeParts(timestamp, timezone) + const parts = getDateTimeParts(timestamp, timezone) switch (mode) { case 'hourly': return ( - zonedDateTimePartsToUtcTimestamp( + dateTimePartsToUtcTimestamp( { ...parts, minutes: 59, seconds: 59 }, timezone ) + 999 @@ -111,7 +111,7 @@ export function normalizeToEndOfSamplingPeriod( case 'daily': return ( - zonedDateTimePartsToUtcTimestamp( + dateTimePartsToUtcTimestamp( { ...parts, hours: 23, minutes: 59, seconds: 59 }, timezone ) + 999 @@ -122,7 +122,7 @@ export function normalizeToEndOfSamplingPeriod( const weekEnd = shiftCalendarDays(weekStart, 6) return ( - zonedDateTimePartsToUtcTimestamp( + dateTimePartsToUtcTimestamp( { ...weekEnd, hours: 23, diff --git a/src/ui/time-range-picker.logic.ts b/src/ui/time-range-picker.logic.ts index a9201f86d..92d8b9f6a 100644 --- a/src/ui/time-range-picker.logic.ts +++ b/src/ui/time-range-picker.logic.ts @@ -1,5 +1,5 @@ import { z } from 'zod' -import { zonedDateTimePartsToUtcDate } from '@/features/dashboard/timezone/date-time' +import { dateTimePartsToUtcDate } from '@/features/dashboard/timezone/date-time' import type { Timezone } from '@/features/dashboard/timezone/schema' import { getBrowserTimezone } from '@/features/dashboard/timezone/utils' @@ -186,7 +186,7 @@ export function parsePickerDateTime( return null } - return zonedDateTimePartsToUtcDate( + return dateTimePartsToUtcDate( { year: parsedDate.getUTCFullYear(), month: parsedDate.getUTCMonth() + 1, diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx index 6d290be12..a7f1dfbe1 100644 --- a/src/ui/time-range-picker.tsx +++ b/src/ui/time-range-picker.tsx @@ -5,9 +5,9 @@ import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' import { - formatZonedDateTimeInput, + formatDateTimeInput, useTimezone, - zonedInstantToCalendarDate, + instantToCalendarDate, } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' @@ -63,18 +63,18 @@ export function TimeRangePicker({ const maxBoundMs = bounds?.max?.getTime() const startParts = useMemo( - () => formatZonedDateTimeInput(startDateTime, timezone), + () => formatDateTimeInput(startDateTime, timezone), [startDateTime, timezone] ) const endParts = useMemo( - () => formatZonedDateTimeInput(endDateTime, timezone), + () => formatDateTimeInput(endDateTime, timezone), [endDateTime, timezone] ) const calendarMinDate = useMemo( () => minBoundMs !== undefined - ? zonedInstantToCalendarDate(minBoundMs, timezone) + ? instantToCalendarDate(minBoundMs, timezone) : undefined, [minBoundMs, timezone] ) @@ -82,7 +82,7 @@ export function TimeRangePicker({ const calendarMaxDate = useMemo( () => maxBoundMs !== undefined - ? zonedInstantToCalendarDate(maxBoundMs, timezone) + ? instantToCalendarDate(maxBoundMs, timezone) : undefined, [maxBoundMs, timezone] ) diff --git a/tests/unit/time-range-picker-logic.test.ts b/tests/unit/time-range-picker-logic.test.ts index 11f56bfb8..29de27be6 100644 --- a/tests/unit/time-range-picker-logic.test.ts +++ b/tests/unit/time-range-picker-logic.test.ts @@ -1,6 +1,6 @@ import { startOfDay } from 'date-fns' import { describe, expect, it, vi } from 'vitest' -import { zonedInstantToCalendarDate } from '@/features/dashboard/timezone' +import { instantToCalendarDate } from '@/features/dashboard/timezone' import { createTimeRangeSchema, normalizeTimeRangeValues, @@ -213,7 +213,7 @@ describe('time-range-picker logic', () => { describe('calendar boundary dates', () => { it('aligns disabled calendar days with dashboard timezone validation', () => { const minBound = new Date('2023-01-01T00:00:00.000Z') - const calendarMin = zonedInstantToCalendarDate(minBound, utc) + const calendarMin = instantToCalendarDate(minBound, utc) vi.stubEnv('TZ', 'America/Los_Angeles') const browserLocalMin = startOfDay(minBound) diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index 996b3bf8b..e3c7d7af9 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -5,11 +5,11 @@ import { formatDateParts, formatDateRange, formatTimezoneAbbreviation, - formatZonedDateTimeInput, + formatDateTimeInput, getRelativeDay, - zonedDateTimePartsToUtcDate, - zonedDateTimePartsToUtcTimestamp, - zonedInstantToCalendarDate, + dateTimePartsToUtcDate, + dateTimePartsToUtcTimestamp, + instantToCalendarDate, } from '@/features/dashboard/timezone' import { requireTimezone } from './helpers/timezone' @@ -18,9 +18,9 @@ describe('timezone date-time helpers', () => { const berlin = requireTimezone('Europe/Berlin') const utc = requireTimezone('UTC') - describe('formatZonedDateTimeInput', () => { + describe('formatDateTimeInput', () => { it('formats a UTC timestamp into New York picker parts', () => { - const result = formatZonedDateTimeInput( + const result = formatDateTimeInput( '2026-06-08T13:05:09.000Z', newYork ) @@ -32,7 +32,7 @@ describe('timezone date-time helpers', () => { }) it('formats a UTC timestamp into Berlin picker parts', () => { - const result = formatZonedDateTimeInput( + const result = formatDateTimeInput( '2026-06-08T13:05:09.000Z', berlin ) @@ -44,9 +44,9 @@ describe('timezone date-time helpers', () => { }) }) - describe('zonedInstantToCalendarDate', () => { + describe('instantToCalendarDate', () => { it('maps an instant to a local calendar date from dashboard wall-clock parts', () => { - const calendarDate = zonedInstantToCalendarDate( + const calendarDate = instantToCalendarDate( '2023-01-01T00:00:00.000Z', utc ) @@ -58,7 +58,7 @@ describe('timezone date-time helpers', () => { it('uses the dashboard timezone instead of browser-local day boundaries', () => { const minBound = new Date('2023-01-01T00:00:00.000Z') - const calendarMin = zonedInstantToCalendarDate(minBound, utc) + const calendarMin = instantToCalendarDate(minBound, utc) vi.stubEnv('TZ', 'America/Los_Angeles') const browserLocalMin = startOfDay(minBound) @@ -74,7 +74,7 @@ describe('timezone date-time helpers', () => { }) it('maps max-bound instants to the dashboard wall-clock calendar day', () => { - const calendarMax = zonedInstantToCalendarDate( + const calendarMax = instantToCalendarDate( '2026-02-24T18:17:41.000Z', utc ) @@ -85,9 +85,9 @@ describe('timezone date-time helpers', () => { }) }) - describe('zonedDateTimePartsToUtcDate', () => { + describe('dateTimePartsToUtcDate', () => { it('converts New York wall-clock parts to UTC', () => { - const result = zonedDateTimePartsToUtcDate( + const result = dateTimePartsToUtcDate( { year: 2026, month: 6, @@ -103,7 +103,7 @@ describe('timezone date-time helpers', () => { }) it('converts UTC wall-clock parts without offset changes', () => { - const result = zonedDateTimePartsToUtcTimestamp( + const result = dateTimePartsToUtcTimestamp( { year: 2026, month: 6, diff --git a/tests/unit/usage-time-range-presets.test.ts b/tests/unit/usage-time-range-presets.test.ts index ac078cfc7..ef4cb7129 100644 --- a/tests/unit/usage-time-range-presets.test.ts +++ b/tests/unit/usage-time-range-presets.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { - formatZonedDateTimeInput, + formatDateTimeInput, type Timezone, } from '@/features/dashboard/timezone' import { getUsageTimeRangePresets } from '@/features/dashboard/usage/constants' @@ -32,22 +32,22 @@ describe('usage time range presets', () => { const newYorkRange = getPresetRange(newYork, 'last-90-days') const losAngelesRange = getPresetRange(losAngeles, 'last-90-days') - expect(formatZonedDateTimeInput(newYorkRange.start, newYork)).toEqual({ + expect(formatDateTimeInput(newYorkRange.start, newYork)).toEqual({ date: '2026/03/13', time: '00:00:00', }) - expect(formatZonedDateTimeInput(newYorkRange.end, newYork)).toEqual({ + expect(formatDateTimeInput(newYorkRange.end, newYork)).toEqual({ date: '2026/06/10', time: '23:59:59', }) - expect(formatZonedDateTimeInput(losAngelesRange.start, losAngeles)).toEqual( + expect(formatDateTimeInput(losAngelesRange.start, losAngeles)).toEqual( { date: '2026/03/13', time: '00:00:00', } ) - expect(formatZonedDateTimeInput(losAngelesRange.end, losAngeles)).toEqual({ + expect(formatDateTimeInput(losAngelesRange.end, losAngeles)).toEqual({ date: '2026/06/10', time: '23:59:59', }) From 556a7a1ffe1c928016bc0c99087b758ce8f0c0cb Mon Sep 17 00:00:00 2001 From: sarimrmalik <60831757+sarimrmalik@users.noreply.github.com> Date: Fri, 12 Jun 2026 21:24:41 +0000 Subject: [PATCH 36/51] style: apply biome formatting --- src/features/dashboard/timezone/date-time.ts | 5 +++-- src/features/dashboard/timezone/index.ts | 12 +++++------ src/features/dashboard/usage/constants.ts | 2 +- .../dashboard/usage/sampling-utils.ts | 4 ++-- src/ui/time-range-picker.tsx | 2 +- tests/unit/timezone-date-time.test.ts | 21 ++++++------------- tests/unit/usage-time-range-presets.test.ts | 10 ++++----- 7 files changed, 23 insertions(+), 33 deletions(-) diff --git a/src/features/dashboard/timezone/date-time.ts b/src/features/dashboard/timezone/date-time.ts index a0b3456ec..1171c9ad7 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/features/dashboard/timezone/date-time.ts @@ -241,8 +241,9 @@ interface FormatDateRangeOptions { format?: DateFormat } -const TIMEZONE_INCLUSIVE_DATE_FORMAT_PRESETS: ReadonlySet = - new Set(['compact-timestamp', 'exact-timestamp']) +const TIMEZONE_INCLUSIVE_DATE_FORMAT_PRESETS: ReadonlySet = new Set( + ['compact-timestamp', 'exact-timestamp'] +) const formatDateRange = ( start: string | number | Date, diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index a25f5e693..e5f24eaa9 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -1,18 +1,18 @@ export { TimezoneProvider, useTimezone } from './context' -export type { DateFormat, CalendarDateTimeParts } from './date-time' +export type { CalendarDateTimeParts, DateFormat } from './date-time' export { + dateTimePartsToUtcDate, + dateTimePartsToUtcTimestamp, formatDate, formatDateParts, formatDateRange, - formatTimezoneAbbreviation, formatDateTimeInput, - getRelativeDay, + formatTimezoneAbbreviation, getDateParts, getDateTimeParts, - shiftCalendarDays, - dateTimePartsToUtcDate, - dateTimePartsToUtcTimestamp, + getRelativeDay, instantToCalendarDate, + shiftCalendarDays, } from './date-time' export type { Timezone } from './schema' export { TimezoneSchema } from './schema' diff --git a/src/features/dashboard/usage/constants.ts b/src/features/dashboard/usage/constants.ts index 19eb0b444..c05608ac9 100644 --- a/src/features/dashboard/usage/constants.ts +++ b/src/features/dashboard/usage/constants.ts @@ -1,8 +1,8 @@ import { + dateTimePartsToUtcTimestamp, getDateParts, shiftCalendarDays, type Timezone, - dateTimePartsToUtcTimestamp, } from '@/features/dashboard/timezone' import { formatAxisNumber } from '@/lib/utils/formatting' import type { TimeRangePreset } from '@/ui/time-range-presets' diff --git a/src/features/dashboard/usage/sampling-utils.ts b/src/features/dashboard/usage/sampling-utils.ts index 371065a68..c298077a6 100644 --- a/src/features/dashboard/usage/sampling-utils.ts +++ b/src/features/dashboard/usage/sampling-utils.ts @@ -1,10 +1,10 @@ import type { UsageResponse } from '@/core/modules/billing/models' import { + type CalendarDateTimeParts, + dateTimePartsToUtcTimestamp, getDateTimeParts, shiftCalendarDays, type Timezone, - type CalendarDateTimeParts, - dateTimePartsToUtcTimestamp, } from '@/features/dashboard/timezone' import { HOURLY_SAMPLING_THRESHOLD_DAYS, diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx index a7f1dfbe1..c92656ce8 100644 --- a/src/ui/time-range-picker.tsx +++ b/src/ui/time-range-picker.tsx @@ -6,8 +6,8 @@ import { useForm } from 'react-hook-form' import { formatDateTimeInput, - useTimezone, instantToCalendarDate, + useTimezone, } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index e3c7d7af9..430a50008 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -1,14 +1,14 @@ import { startOfDay } from 'date-fns' import { describe, expect, it, vi } from 'vitest' import { + dateTimePartsToUtcDate, + dateTimePartsToUtcTimestamp, formatDate, formatDateParts, formatDateRange, - formatTimezoneAbbreviation, formatDateTimeInput, + formatTimezoneAbbreviation, getRelativeDay, - dateTimePartsToUtcDate, - dateTimePartsToUtcTimestamp, instantToCalendarDate, } from '@/features/dashboard/timezone' import { requireTimezone } from './helpers/timezone' @@ -20,10 +20,7 @@ describe('timezone date-time helpers', () => { describe('formatDateTimeInput', () => { it('formats a UTC timestamp into New York picker parts', () => { - const result = formatDateTimeInput( - '2026-06-08T13:05:09.000Z', - newYork - ) + const result = formatDateTimeInput('2026-06-08T13:05:09.000Z', newYork) expect(result).toEqual({ date: '2026/06/08', @@ -32,10 +29,7 @@ describe('timezone date-time helpers', () => { }) it('formats a UTC timestamp into Berlin picker parts', () => { - const result = formatDateTimeInput( - '2026-06-08T13:05:09.000Z', - berlin - ) + const result = formatDateTimeInput('2026-06-08T13:05:09.000Z', berlin) expect(result).toEqual({ date: '2026/06/08', @@ -74,10 +68,7 @@ describe('timezone date-time helpers', () => { }) it('maps max-bound instants to the dashboard wall-clock calendar day', () => { - const calendarMax = instantToCalendarDate( - '2026-02-24T18:17:41.000Z', - utc - ) + const calendarMax = instantToCalendarDate('2026-02-24T18:17:41.000Z', utc) expect(calendarMax.getFullYear()).toBe(2026) expect(calendarMax.getMonth()).toBe(1) diff --git a/tests/unit/usage-time-range-presets.test.ts b/tests/unit/usage-time-range-presets.test.ts index ef4cb7129..411386cc4 100644 --- a/tests/unit/usage-time-range-presets.test.ts +++ b/tests/unit/usage-time-range-presets.test.ts @@ -41,12 +41,10 @@ describe('usage time range presets', () => { time: '23:59:59', }) - expect(formatDateTimeInput(losAngelesRange.start, losAngeles)).toEqual( - { - date: '2026/03/13', - time: '00:00:00', - } - ) + expect(formatDateTimeInput(losAngelesRange.start, losAngeles)).toEqual({ + date: '2026/03/13', + time: '00:00:00', + }) expect(formatDateTimeInput(losAngelesRange.end, losAngeles)).toEqual({ date: '2026/06/10', time: '23:59:59', From 0c642be9a82597659c4f2854086f078ce665690c Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Mon, 15 Jun 2026 11:20:53 -0400 Subject: [PATCH 37/51] refactor(timezone): reorganize timezone imports and utilities - Removed direct imports of `formatDate`, `formatDateParts`, and `useTimezone` from the timezone module. - Updated imports to utilize the new formatting utilities from `@/lib/utils/formatting`. - Cleaned up the timezone settings and related components for better modularity and maintainability. --- .../dashboard/account/timezone-settings.tsx | 5 +- src/features/dashboard/billing/invoices.tsx | 4 +- src/features/dashboard/build/header-cells.tsx | 3 +- src/features/dashboard/build/logs-cells.tsx | 6 +- .../dashboard/members/member-table-row.tsx | 3 +- .../dashboard/sandbox/events/table.tsx | 3 +- .../dashboard/sandbox/header/ended-at.tsx | 7 +- .../dashboard/sandbox/header/started-at.tsx | 7 +- .../sandbox/inspect/stopped-banner.tsx | 3 +- .../dashboard/sandbox/logs/logs-cells.tsx | 3 +- .../monitoring/components/chart-overlays.tsx | 3 +- .../monitoring-time-range-controls.tsx | 3 +- .../monitoring/utils/chart-data-utils.ts | 3 +- .../dashboard/sandboxes/list/table-cells.tsx | 3 +- .../charts/concurrent-chart/hooks.ts | 6 +- .../charts/startrate-chart/hooks.ts | 4 +- .../charts/team-metrics-chart/utils.ts | 6 +- .../sandboxes/monitoring/time-picker/utils.ts | 6 +- .../dashboard/settings/general/team-info.tsx | 3 +- .../settings/keys/api-keys-table-row.tsx | 3 +- .../dashboard/settings/webhooks/table-row.tsx | 3 +- .../detail/overview/template-section.tsx | 3 +- .../dashboard/templates/list/table-cells.tsx | 3 +- src/features/dashboard/timezone/index.ts | 15 - src/features/dashboard/usage/constants.ts | 6 +- src/features/dashboard/usage/display-utils.ts | 8 +- .../usage/redesign/single-metric-chart.tsx | 10 +- .../dashboard/usage/sampling-utils.ts | 4 +- .../usage/usage-time-range-controls.tsx | 3 +- src/lib/utils/formatting.ts | 452 ------------------ src/lib/utils/formatting/index.ts | 104 ++++ .../utils/formatting/time.ts} | 232 ++++++++- src/ui/time-input.tsx | 6 +- src/ui/time-range-picker.logic.ts | 4 +- src/ui/time-range-picker.tsx | 7 +- tests/unit/helpers/timezone.ts | 3 +- tests/unit/time-range-picker-logic.test.ts | 2 +- tests/unit/timezone-date-time.test.ts | 2 +- tests/unit/usage-time-range-presets.test.ts | 6 +- 39 files changed, 407 insertions(+), 550 deletions(-) delete mode 100644 src/lib/utils/formatting.ts create mode 100644 src/lib/utils/formatting/index.ts rename src/{features/dashboard/timezone/date-time.ts => lib/utils/formatting/time.ts} (65%) diff --git a/src/features/dashboard/account/timezone-settings.tsx b/src/features/dashboard/account/timezone-settings.tsx index d3b189e12..9dcd52c96 100644 --- a/src/features/dashboard/account/timezone-settings.tsx +++ b/src/features/dashboard/account/timezone-settings.tsx @@ -1,13 +1,12 @@ 'use client' import { useEffect, useMemo, useState } from 'react' +import { type Timezone, useTimezone } from '@/features/dashboard/timezone' import { formatTimezoneLabel, getBrowserTimezone, getTimezones, - type Timezone, - useTimezone, -} from '@/features/dashboard/timezone' +} from '@/features/dashboard/timezone/utils' import { defaultErrorToast, defaultSuccessToast, diff --git a/src/features/dashboard/billing/invoices.tsx b/src/features/dashboard/billing/invoices.tsx index 3fb3efe60..7c3b9ae5c 100644 --- a/src/features/dashboard/billing/invoices.tsx +++ b/src/features/dashboard/billing/invoices.tsx @@ -1,9 +1,9 @@ 'use client' import Link from 'next/link' -import { formatDate, useTimezone } from '@/features/dashboard/timezone' +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' diff --git a/src/features/dashboard/build/header-cells.tsx b/src/features/dashboard/build/header-cells.tsx index 30bb1837b..1ba2d01f7 100644 --- a/src/features/dashboard/build/header-cells.tsx +++ b/src/features/dashboard/build/header-cells.tsx @@ -1,9 +1,10 @@ import Link from 'next/link' import { useEffect, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' -import { formatDate, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { useRouteParams } from '@/lib/hooks/use-route-params' import { + formatDate, formatDurationCompact, formatTimeAgoCompact, } from '@/lib/utils/formatting' diff --git a/src/features/dashboard/build/logs-cells.tsx b/src/features/dashboard/build/logs-cells.tsx index 2cdc6affe..ffd4935b9 100644 --- a/src/features/dashboard/build/logs-cells.tsx +++ b/src/features/dashboard/build/logs-cells.tsx @@ -3,8 +3,8 @@ import { LogLevelBadge, LogMessage, } from '@/features/dashboard/common/log-cells' -import { formatDate, useTimezone } from '@/features/dashboard/timezone' -import { formatDurationCompact } from '@/lib/utils/formatting' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDate, formatDurationCompact } from '@/lib/utils/formatting' import CopyButtonInline from '@/ui/copy-button-inline' export const LogLevel = ({ level }: { level: BuildLogModel['level'] }) => { @@ -30,7 +30,7 @@ export const Timestamp = ({ className="font-mono group prose-table-numeric" > - {formatDurationCompact(millisAfterStart, true, true)} + {formatDurationCompact(millisAfterStart, true)} {formatDate(timestampUnix, { diff --git a/src/features/dashboard/members/member-table-row.tsx b/src/features/dashboard/members/member-table-row.tsx index 42c2dbe16..d73bf68a7 100644 --- a/src/features/dashboard/members/member-table-row.tsx +++ b/src/features/dashboard/members/member-table-row.tsx @@ -10,12 +10,13 @@ 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 { formatDate, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { defaultErrorToast, defaultSuccessToast, useToast, } from '@/lib/hooks/use-toast' +import { formatDate } from '@/lib/utils/formatting' import { useTRPC } from '@/trpc/client' import { E2BSquareBadge } from '@/ui/brand' import { Avatar, AvatarFallback, AvatarImage } from '@/ui/primitives/avatar' diff --git a/src/features/dashboard/sandbox/events/table.tsx b/src/features/dashboard/sandbox/events/table.tsx index a3348fdcf..e7ae6bd4d 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 { formatDateParts, useTimezone } from '@/features/dashboard/timezone' +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' diff --git a/src/features/dashboard/sandbox/header/ended-at.tsx b/src/features/dashboard/sandbox/header/ended-at.tsx index 146872a7e..ac4903895 100644 --- a/src/features/dashboard/sandbox/header/ended-at.tsx +++ b/src/features/dashboard/sandbox/header/ended-at.tsx @@ -1,10 +1,7 @@ 'use client' -import { - formatDate, - getRelativeDay, - useTimezone, -} from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDate, getRelativeDay } from '@/lib/utils/formatting' import CopyButton from '@/ui/copy-button' import { useSandboxContext } from '../context' diff --git a/src/features/dashboard/sandbox/header/started-at.tsx b/src/features/dashboard/sandbox/header/started-at.tsx index 093365a49..903b37a94 100644 --- a/src/features/dashboard/sandbox/header/started-at.tsx +++ b/src/features/dashboard/sandbox/header/started-at.tsx @@ -1,10 +1,7 @@ 'use client' -import { - formatDate, - getRelativeDay, - useTimezone, -} from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDate, getRelativeDay } from '@/lib/utils/formatting' import CopyButton from '@/ui/copy-button' import { useSandboxContext } from '../context' diff --git a/src/features/dashboard/sandbox/inspect/stopped-banner.tsx b/src/features/dashboard/sandbox/inspect/stopped-banner.tsx index 372568514..45edfb0f6 100644 --- a/src/features/dashboard/sandbox/inspect/stopped-banner.tsx +++ b/src/features/dashboard/sandbox/inspect/stopped-banner.tsx @@ -2,8 +2,9 @@ import { AnimatePresence, motion } from 'motion/react' import { useMemo } from 'react' -import { formatDate, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/utils/formatting' import { CardDescription, CardHeader, diff --git a/src/features/dashboard/sandbox/logs/logs-cells.tsx b/src/features/dashboard/sandbox/logs/logs-cells.tsx index a6e9aa25b..ba7830c1f 100644 --- a/src/features/dashboard/sandbox/logs/logs-cells.tsx +++ b/src/features/dashboard/sandbox/logs/logs-cells.tsx @@ -2,7 +2,8 @@ import type { SandboxLogModel } from '@/core/modules/sandboxes/models' import { LogLevelBadge } from '@/features/dashboard/common/log-cells' -import { formatDateParts, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDateParts } from '@/lib/utils/formatting' import CopyButtonInline from '@/ui/copy-button-inline' export const LogLevel = ({ level }: { level: SandboxLogModel['level'] }) => { diff --git a/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx b/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx index 619ec86d8..a79d9d972 100644 --- a/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx +++ b/src/features/dashboard/sandbox/monitoring/components/chart-overlays.tsx @@ -1,5 +1,6 @@ -import { formatDate, type Timezone } from '@/features/dashboard/timezone' +import type { Timezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/utils/formatting' import { AddIcon, BlockIcon, 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 3c979ab3f..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,8 +1,9 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { formatDate, useTimezone } from '@/features/dashboard/timezone' +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' 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 99be68dad..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,4 +1,5 @@ -import { formatDate, type Timezone } from '@/features/dashboard/timezone' +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 { diff --git a/src/features/dashboard/sandboxes/list/table-cells.tsx b/src/features/dashboard/sandboxes/list/table-cells.tsx index 28369114d..9213d5402 100644 --- a/src/features/dashboard/sandboxes/list/table-cells.tsx +++ b/src/features/dashboard/sandboxes/list/table-cells.tsx @@ -5,7 +5,8 @@ import Link from 'next/link' import { useMemo } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import ResourceUsage from '@/features/dashboard/common/resource-usage' -import { formatDateParts, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDateParts } from '@/lib/utils/formatting' import { JsonPopover } from '@/ui/json-popover' import { Badge } from '@/ui/primitives/badge' import { Button } from '@/ui/primitives/button' 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 5cff10a92..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,11 +1,11 @@ import { useMemo } from 'react' import type { TeamMetricsResponse } from '@/core/modules/sandboxes/models.client' +import { useTimezone } from '@/features/dashboard/timezone' import { formatDate, formatDateRange, - useTimezone, -} from '@/features/dashboard/timezone' -import { formatNumber } from '@/lib/utils/formatting' + formatNumber, +} from '@/lib/utils/formatting' import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe' import { transformMetrics } from '../team-metrics-chart' import { calculateAverage } from '../team-metrics-chart/utils' 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 e2b0c5dda..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,7 +1,7 @@ import { useMemo } from 'react' import type { TeamMetricsResponse } from '@/core/modules/sandboxes/models.client' -import { formatDate, useTimezone } from '@/features/dashboard/timezone' -import { 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' 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 815b2fb6a..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,10 +1,10 @@ import type { ClientTeamMetric } from '@/core/modules/sandboxes/models.client' +import type { Timezone } from '@/features/dashboard/timezone' import { type DateFormat, + formatAxisNumber, formatDate, - type Timezone, -} from '@/features/dashboard/timezone' -import { formatAxisNumber } from '@/lib/utils/formatting' +} from '@/lib/utils/formatting' import type { TeamMetricDataPoint } from './types' const resolveTimeAxisLabelFormat = (rangeMs: number): DateFormat => { diff --git a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts index 7723161ef..17773cd9c 100644 --- a/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts +++ b/src/features/dashboard/sandboxes/monitoring/time-picker/utils.ts @@ -1,8 +1,6 @@ import { calculateStepForDuration } from '@/features/dashboard/sandboxes/monitoring/utils' -import { - formatDateTimeInput, - type Timezone, -} from '@/features/dashboard/timezone' +import type { Timezone } from '@/features/dashboard/timezone' +import { formatDateTimeInput } from '@/lib/utils/formatting' import type { TimeframeState } from '@/lib/utils/timeframe' import { CUSTOM_PANEL_HEIGHT, diff --git a/src/features/dashboard/settings/general/team-info.tsx b/src/features/dashboard/settings/general/team-info.tsx index e0ade51f9..0706e479e 100644 --- a/src/features/dashboard/settings/general/team-info.tsx +++ b/src/features/dashboard/settings/general/team-info.tsx @@ -1,7 +1,8 @@ 'use client' import { useDashboard } from '@/features/dashboard/context' -import { formatDate, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' +import { formatDate } from '@/lib/utils/formatting' import CopyButtonInline from '@/ui/copy-button-inline' const InfoRow = ({ label, value }: { label: string; value: string }) => ( 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 312314714..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 { formatDate, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { defaultSuccessToast, useToast } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { formatDate } from '@/lib/utils/formatting' import { E2BSquareBadge } from '@/ui/brand' import { Button } from '@/ui/primitives/button' import { KeyIcon, RemoveIcon } from '@/ui/primitives/icons' diff --git a/src/features/dashboard/settings/webhooks/table-row.tsx b/src/features/dashboard/settings/webhooks/table-row.tsx index 83a300abd..36ce39596 100644 --- a/src/features/dashboard/settings/webhooks/table-row.tsx +++ b/src/features/dashboard/settings/webhooks/table-row.tsx @@ -4,10 +4,11 @@ import Link from 'next/link' import { Fragment, useState } from 'react' import { PROTECTED_URLS } from '@/configs/urls' import { SandboxLifecycleEventTypeSchema } from '@/core/modules/sandboxes/lifecycle-event-types' -import { formatDate, useTimezone } from '@/features/dashboard/timezone' +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 { diff --git a/src/features/dashboard/templates/detail/overview/template-section.tsx b/src/features/dashboard/templates/detail/overview/template-section.tsx index e89ff5cfc..534d27028 100644 --- a/src/features/dashboard/templates/detail/overview/template-section.tsx +++ b/src/features/dashboard/templates/detail/overview/template-section.tsx @@ -3,8 +3,9 @@ import type { CSSProperties, ReactNode } from 'react' import type { TemplateDetail } from '@/core/modules/templates/models' import { getTemplateDisplayName } from '@/features/dashboard/templates/helpers' -import { formatDateParts, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { useClipboard } from '@/lib/hooks/use-clipboard' +import { formatDateParts } from '@/lib/utils/formatting' import { cn } from '@/lib/utils/ui' import { IconButton } from '@/ui/primitives/icon-button' import { CheckmarkIcon, CopyIcon } from '@/ui/primitives/icons' diff --git a/src/features/dashboard/templates/list/table-cells.tsx b/src/features/dashboard/templates/list/table-cells.tsx index 03663b1d8..2140eee6b 100644 --- a/src/features/dashboard/templates/list/table-cells.tsx +++ b/src/features/dashboard/templates/list/table-cells.tsx @@ -4,7 +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 { formatDateParts, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { useClipboard } from '@/lib/hooks/use-clipboard' import { defaultErrorToast, @@ -12,6 +12,7 @@ import { useToast, } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' +import { formatDateParts } from '@/lib/utils/formatting' import { isVersionCompatible } from '@/lib/utils/version' import { useTRPC } from '@/trpc/client' import { AlertDialog } from '@/ui/alert-dialog' diff --git a/src/features/dashboard/timezone/index.ts b/src/features/dashboard/timezone/index.ts index e5f24eaa9..62e32926f 100644 --- a/src/features/dashboard/timezone/index.ts +++ b/src/features/dashboard/timezone/index.ts @@ -1,19 +1,4 @@ export { TimezoneProvider, useTimezone } from './context' -export type { CalendarDateTimeParts, DateFormat } from './date-time' -export { - dateTimePartsToUtcDate, - dateTimePartsToUtcTimestamp, - formatDate, - formatDateParts, - formatDateRange, - formatDateTimeInput, - formatTimezoneAbbreviation, - getDateParts, - getDateTimeParts, - getRelativeDay, - instantToCalendarDate, - shiftCalendarDays, -} from './date-time' export type { Timezone } from './schema' export { TimezoneSchema } from './schema' export { diff --git a/src/features/dashboard/usage/constants.ts b/src/features/dashboard/usage/constants.ts index c05608ac9..227ddb81d 100644 --- a/src/features/dashboard/usage/constants.ts +++ b/src/features/dashboard/usage/constants.ts @@ -1,10 +1,10 @@ +import type { Timezone } from '@/features/dashboard/timezone' import { dateTimePartsToUtcTimestamp, + formatAxisNumber, getDateParts, shiftCalendarDays, - type Timezone, -} from '@/features/dashboard/timezone' -import { formatAxisNumber } from '@/lib/utils/formatting' +} from '@/lib/utils/formatting' import type { TimeRangePreset } from '@/ui/time-range-presets' import type { ComputeChartConfig, diff --git a/src/features/dashboard/usage/display-utils.ts b/src/features/dashboard/usage/display-utils.ts index 37ca7220d..a37bb57ac 100644 --- a/src/features/dashboard/usage/display-utils.ts +++ b/src/features/dashboard/usage/display-utils.ts @@ -1,5 +1,9 @@ -import { getDateParts, type Timezone } from '@/features/dashboard/timezone' -import { formatCurrency, formatNumber } from '@/lib/utils/formatting' +import type { Timezone } from '@/features/dashboard/timezone' +import { + formatCurrency, + formatNumber, + getDateParts, +} from '@/lib/utils/formatting' import { determineSamplingMode, normalizeToEndOfSamplingPeriod, diff --git a/src/features/dashboard/usage/redesign/single-metric-chart.tsx b/src/features/dashboard/usage/redesign/single-metric-chart.tsx index 77dc136a5..d00cb0c2c 100644 --- a/src/features/dashboard/usage/redesign/single-metric-chart.tsx +++ b/src/features/dashboard/usage/redesign/single-metric-chart.tsx @@ -1,10 +1,8 @@ 'use client' -import { - formatZonedDateRange, - useTimezone, -} from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' +import { formatDateRange } from '@/lib/utils/formatting' import { useUsageCharts } from '../usage-charts-context' import { USAGE_METRICS, type UsageMetricKey } from './metrics' import { type HoverSection, UsageAreaChart } from './usage-area-chart' @@ -80,9 +78,7 @@ export function SingleMetricChart({ total {meta.label} for{' '} - {formatZonedDateRange(timeframe.start, timeframe.end, timezone, { - includeTimezone: false, - })} + {formatDateRange(timeframe.start, timeframe.end, { timezone })}
diff --git a/src/features/dashboard/usage/sampling-utils.ts b/src/features/dashboard/usage/sampling-utils.ts index c298077a6..eb3def7d0 100644 --- a/src/features/dashboard/usage/sampling-utils.ts +++ b/src/features/dashboard/usage/sampling-utils.ts @@ -1,11 +1,11 @@ import type { UsageResponse } from '@/core/modules/billing/models' +import type { Timezone } from '@/features/dashboard/timezone' import { type CalendarDateTimeParts, dateTimePartsToUtcTimestamp, getDateTimeParts, shiftCalendarDays, - type Timezone, -} from '@/features/dashboard/timezone' +} from '@/lib/utils/formatting' import { HOURLY_SAMPLING_THRESHOLD_DAYS, WEEKLY_SAMPLING_THRESHOLD_DAYS, diff --git a/src/features/dashboard/usage/usage-time-range-controls.tsx b/src/features/dashboard/usage/usage-time-range-controls.tsx index 4f5489e48..f17590951 100644 --- a/src/features/dashboard/usage/usage-time-range-controls.tsx +++ b/src/features/dashboard/usage/usage-time-range-controls.tsx @@ -1,8 +1,9 @@ 'use client' import { useCallback, useMemo, useState } from 'react' -import { formatDateRange, useTimezone } from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' +import { formatDateRange } from '@/lib/utils/formatting' import { findMatchingPreset } from '@/lib/utils/time-range' import { formatTimeframeAsISO8601Interval } from '@/lib/utils/timeframe' import CopyButton from '@/ui/copy-button' diff --git a/src/lib/utils/formatting.ts b/src/lib/utils/formatting.ts deleted file mode 100644 index d22b5bdd5..000000000 --- a/src/lib/utils/formatting.ts +++ /dev/null @@ -1,452 +0,0 @@ -/** - * Formatting utilities for dates, times, and numbers across the application - * Uses date-fns for date/time formatting and toLocaleString for numbers - */ - -import * as chrono from 'chrono-node' -import { format, isValid } from 'date-fns' -import { formatInTimeZone } from 'date-fns-tz' - -// ============================================================================ -// Date & Time Formatting -// ============================================================================ - -/** - * Format a timestamp for display in charts and tooltips in user's local timezone - * @param timestamp - Unix timestamp in milliseconds or Date object - * @returns Formatted date string in user's local timezone (e.g., "Jan 5, 2:30:45 PM") - */ -export function formatChartTimestampLocal( - timestamp: number | string | Date, - showDate: boolean = false -): string { - const date = new Date(timestamp) - - if (showDate) { - return format(date, 'MMM d') - } - // format in user's local timezone instead of UTC - return format(date, 'h:mm:ss a') -} - -/** - * Format a timestamp for display in charts and tooltips - * @param timestamp - Unix timestamp in milliseconds or Date object - * @returns Formatted date string in UTC timezone (e.g., "Jan 5, 2:30:45 PM") - */ -export function formatChartTimestampUTC( - timestamp: number | string | Date, - showDate: boolean = false -): string { - const date = new Date(timestamp) - - if (showDate) { - return formatInTimeZone(date, 'UTC', 'MMM d') - } - - return formatInTimeZone(date, 'UTC', 'h:mm:ss a') -} - -/** Formats a timestamp with a relative day label, e.g. "2026-05-19T14:35:10Z" -> "Today, 2:35:10 PM". */ -export const formatDisplayTimestamp = (value: string | number | Date) => { - const date = new Date(value) - const now = new Date() - const yesterday = new Date() - yesterday.setDate(now.getDate() - 1) - - const isToday = date.toDateString() === now.toDateString() - const isYesterday = date.toDateString() === yesterday.toDateString() - const prefix = isToday - ? 'Today' - : isYesterday - ? 'Yesterday' - : date.toLocaleDateString('en-US') - const timeStr = date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - }) - - return `${prefix}, ${timeStr}` -} - -/** Formats elapsed time as a compact relative label; e.g. `new Date(Date.now() - 7200000)` -> `"2h ago"` */ -export const formatRelativeAgo = (date: Date): string => { - const now = Date.now() - const timestamp = date.getTime() - const seconds = Math.floor((now - timestamp) / 1000) - if (seconds < 60) return `${seconds}s ago` - - const minutes = Math.floor(seconds / 60) - if (minutes < 60) return `${minutes} min ago` - - const hours = Math.floor(minutes / 60) - if (hours < 24) return `${hours}h ago` - - const days = Math.floor(hours / 24) - if (days < 7) return `${days}d ago` - - const weeks = Math.floor(days / 7) - if (weeks < 5) return `${weeks}w ago` - - if (days < 365) { - const months = Math.floor(days / 30) - return `${months}mo ago` - } - - const years = Math.floor(days / 365) - return `${years}y ago` -} - -/** - * Parse and format a UTC date string into components - * @param date - Date string or Date object - * @returns Object with date components - */ -export function parseUTCDateComponents(date: string | Date) { - const dateTimeString = new Date(date).toUTCString() - const [day, dateStr, month, year, time, timezone] = dateTimeString.split(' ') - - return { - day, - date: dateStr, - month, - year, - time, - timezone, - full: dateTimeString, - } -} - -/** - * Format time axis labels for charts - * @param value - Timestamp or date value - * @param showDate - Whether to show the date (for day boundaries) - * @param useLocal - Whether to use local timezone instead of UTC - * @returns Formatted label - */ -export function formatTimeAxisLabel( - value: string | number, - showDate: boolean = false, - useLocal: boolean = true -): string { - const date = new Date(value) - - if (useLocal) { - return formatChartTimestampLocal(date, showDate) - } - - return formatChartTimestampUTC(date, showDate) -} -/** - * Format a duration in milliseconds to human-readable text - * @param durationMs - Duration in milliseconds - * @returns Human-readable duration (e.g., "5 seconds", "2 minutes", "1 hour") - */ -export function formatDuration(durationMs: number): string { - const seconds = Math.floor(durationMs / 1000) - - if (seconds < 60) { - return `${seconds} second${seconds !== 1 ? 's' : ''}` - } else if (seconds < 3600) { - const minutes = Math.floor(seconds / 60) - return `${minutes} minute${minutes !== 1 ? 's' : ''}` - } else { - const hours = Math.floor(seconds / 3600) - return `${hours} hour${hours !== 1 ? 's' : ''}` - } -} - -export function formatDurationCompact( - ms: number, - showDecimalSeconds = false, - padTrailingField = false -): string { - const seconds = Math.floor(ms / 1000) - const minutes = Math.floor(seconds / 60) - const hours = Math.floor(minutes / 60) - const pad = (n: number) => - padTrailingField ? n.toString().padStart(2, '0') : `${n}` - - if (hours > 0) { - const remainingMinutes = minutes % 60 - return `${hours}h ${pad(remainingMinutes)}m` - } - if (minutes > 0) { - const remainingSeconds = seconds % 60 - return `${minutes}m ${pad(remainingSeconds)}s` - } - return showDecimalSeconds - ? `${seconds}.${Math.floor((ms % 1000) / 100)}s` - : `${seconds}s` -} - -export function formatTimeAgoCompact(ms: number): string { - const minutes = Math.floor(ms / 1000 / 60) - const hours = Math.floor(minutes / 60) - const days = Math.floor(hours / 24) - const months = Math.floor(days / 30) - - if (minutes < 1) { - return '< 1m ago' - } - if (hours < 1) { - return `${minutes}m ago` - } - if (days < 1) { - const remainingMinutes = minutes % 60 - return `${hours}h ${remainingMinutes}m ago` - } - if (months < 1) { - const remainingHours = hours % 24 - return `${days}d ${remainingHours}h ago` - } - - const remainingDays = days % 30 - return `${months}mo ${remainingDays}d ago` -} - -/** - * Format an averaging period text (e.g., "5 seconds average") - * @param stepMs - Step/period in milliseconds - * @returns Formatted averaging period text - */ -export function formatAveragingPeriod(stepMs: number): string { - return `${formatDuration(stepMs)} average` -} - -// ============================================================================ -// Number Formatting -// ============================================================================ - -/** - * Format a number with locale-specific separators - * @param value - Number to format - * @param locale - Locale to use (defaults to 'en-US') - * @param maxFractionDigits - Maximum decimal places to show (defaults to 0 for whole numbers) - * @returns Formatted number string - */ -export function formatNumber( - value: number, - locale: string = 'en-US', - maxFractionDigits: number = 0 -): string { - return value.toLocaleString(locale, { - maximumFractionDigits: maxFractionDigits, - }) -} - -/** - * Format a decimal number with specified precision - * @param value - Number to format - * @param decimals - Number of decimal places - * @param locale - Locale to use (defaults to 'en-US') - * @returns Formatted number string - */ -export function formatDecimal( - value: number, - decimals: number = 1, - locale: string = 'en-US' -): string { - return value.toLocaleString(locale, { - minimumFractionDigits: decimals, - maximumFractionDigits: decimals, - }) -} - -/** - * Format memory in MB to appropriate unit - * @param memoryMB - Memory in megabytes - * @param locale - Locale to use (defaults to 'en-US') - * @returns Formatted memory string (e.g., "512 MB", "1.5 GB") - */ -export function formatMemory( - memoryMB: number, - locale: string = 'en-US' -): string { - if (memoryMB < 1024) { - return `${formatNumber(memoryMB, locale)} MB` - } - return `${formatDecimal(memoryMB / 1024, 1, locale)} GB` -} - -/** - * Format CPU cores with proper pluralization - * @param cores - Number of CPU cores - * @param locale - Locale to use (defaults to 'en-US') - * @returns Formatted CPU string (e.g., "1 core", "4 cores") - */ -export function formatCPUCores( - cores: number, - locale: string = 'en-US' -): string { - return `${formatNumber(cores, locale)} core${cores !== 1 ? 's' : ''}` -} - -/** - * Returns the singular or plural word for a count - * @param count - Number used to determine singular vs plural form - * @param singular - Singular form of the word - * @param plural - Optional plural form override (defaults to an inferred plural form) - * @returns Singular or plural word (e.g., "member" or "members") - */ -export const pluralize = ( - count: number, - singular: string, - plural?: string -): string => { - if (count === 1) return singular - if (plural) return plural - if (/[sxz]$/i.test(singular) || /(ch|sh)$/i.test(singular)) { - return `${singular}es` - } - if (/[^aeiou]y$/i.test(singular)) { - return `${singular.slice(0, -1)}ies` - } - return `${singular}s` -} - -/** - * Format a number for chart axis labels with smart abbreviation - * Uses whole numbers when possible, abbreviated for large numbers - * @param value - Number to format - * @param locale - Locale to use (defaults to 'en-US') - * @returns Formatted number suitable for chart axes - */ -export function formatAxisNumber( - value: number, - locale: string = 'en-US' -): string { - // For chart axes, we want clean whole numbers when possible - if (Math.abs(value) >= 1000) { - // Use compact notation for large numbers on axes for cleaner look - const formatter = new Intl.NumberFormat(locale, { - notation: 'compact', - maximumFractionDigits: 0, - }) - return formatter.format(value) - } - - if (value < 1 && value > 0) { - return value.toFixed(2) - } - - return formatNumber(value, locale) -} - -// ============================================================================ -// Date Parsing -// ============================================================================ - -/** - * Try to parse a datetime string into a Date object using Chrono - * Supports multiple formats including ISO, timestamps, relative times, natural language, and common formats - * @param input - Date string to parse - * @returns Date object if parsing succeeds, null otherwise - */ -export function tryParseDatetime(input: string): Date | null { - if (!input.trim()) return null - - // Try parsing as timestamp first (for performance with numeric inputs) - const timestamp = Number(input) - if (!Number.isNaN(timestamp)) { - // if timestamp is less than 10 digits, multiply by 1000 to get milliseconds - const date = new Date( - timestamp < 10000000000 ? timestamp * 1000 : timestamp - ) - if (isValid(date)) return date - } - - // we use Chrono for all other formats - handles ISO, natural language, relative times, and common formats - try { - const parsedDate = chrono.parseDate(input) - return parsedDate || null - } catch { - return null - } -} - -// ============================================================================ -// Date/Time Component Formatting -// ============================================================================ - -/** - * Format a date for display with slashes and spaces (DD / MM / YYYY) - * Used in date pickers and forms for better readability - * @param date - Date to format - * @returns Formatted date string with spaces (e.g., "15 / 03 / 2024") - */ -export function formatDateWithSpaces(date: Date | null): string { - if (!date) return '' - const day = String(date.getDate()).padStart(2, '0') - const month = String(date.getMonth() + 1).padStart(2, '0') - const year = date.getFullYear() - return `${day} / ${month} / ${year}` -} - -/** - * Format time components with spaces for display (HH : MM : SS) - * Used in time pickers for better readability - * @param hours - Hours as string or number - * @param minutes - Minutes as string or number - * @param seconds - Seconds as string or number - * @returns Formatted time string with spaces (e.g., "14 : 30 : 45") - */ -export function formatTimeWithSpaces( - hours: string | number, - minutes: string | number, - seconds: string | number -): string { - const h = String(hours).padStart(2, '0') - const m = String(minutes).padStart(2, '0') - const s = String(seconds).padStart(2, '0') - return `${h} : ${m} : ${s}` -} - -/** - * Parse a datetime string into separate date and time components - * Returns date in YYYY/MM/DD format and time in HH:MM:SS format - * @param dateTimeStr - Datetime string to parse - * @returns Object with date and time strings, or empty strings if invalid - */ -export function parseDateTimeComponents(dateTimeStr: string): { - date: string - time: string -} { - if (!dateTimeStr) return { date: '', time: '' } - const parsed = tryParseDatetime(dateTimeStr) - if (!parsed) return { date: '', time: '' } - - const year = parsed.getFullYear() - const month = String(parsed.getMonth() + 1).padStart(2, '0') - const day = String(parsed.getDate()).padStart(2, '0') - const hours = String(parsed.getHours()).padStart(2, '0') - const minutes = String(parsed.getMinutes()).padStart(2, '0') - const seconds = String(parsed.getSeconds()).padStart(2, '0') - - return { - date: `${year}/${month}/${day}`, - time: `${hours}:${minutes}:${seconds}`, - } -} - -/** - * Format a currency amount with the specified currency and locale - * Always displays exactly 2 decimal places with standard rounding - * @param amount - Amount to format - * @param currency - Currency to use (defaults to 'USD') - * @param locale - Locale to use (defaults to 'en-US') - * @returns Formatted currency string (e.g., "$100.00", "100.00 €") - */ -export function formatCurrency( - amount: number, - currency: string = 'USD', - locale: string = 'en-US' -): string { - return Intl.NumberFormat(locale, { - style: 'currency', - currency, - minimumFractionDigits: 2, - maximumFractionDigits: 2, - }).format(amount) -} diff --git a/src/lib/utils/formatting/index.ts b/src/lib/utils/formatting/index.ts new file mode 100644 index 000000000..37fa91a54 --- /dev/null +++ b/src/lib/utils/formatting/index.ts @@ -0,0 +1,104 @@ +export * from './time' + +function formatNumber( + value: number, + locale = 'en-US', + maxFractionDigits = 0 +): string { + return value.toLocaleString(locale, { + maximumFractionDigits: maxFractionDigits, + }) +} + +function formatDecimal(value: number, decimals = 1, locale = 'en-US'): string { + return value.toLocaleString(locale, { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }) +} + +function formatMemory(memoryMB: number, locale = 'en-US'): string { + if (memoryMB < 1024) { + return `${formatNumber(memoryMB, locale)} MB` + } + return `${formatDecimal(memoryMB / 1024, 1, locale)} GB` +} + +function formatCPUCores(cores: number, locale = 'en-US'): string { + return `${formatNumber(cores, locale)} core${cores !== 1 ? 's' : ''}` +} + +const pluralize = ( + count: number, + singular: string, + plural?: string +): string => { + if (count === 1) return singular + if (plural) return plural + if (/[sxz]$/i.test(singular) || /(ch|sh)$/i.test(singular)) { + return `${singular}es` + } + if (/[^aeiou]y$/i.test(singular)) { + return `${singular.slice(0, -1)}ies` + } + return `${singular}s` +} + +function formatAxisNumber(value: number, locale = 'en-US'): string { + if (Math.abs(value) >= 1000) { + const formatter = new Intl.NumberFormat(locale, { + notation: 'compact', + maximumFractionDigits: 0, + }) + return formatter.format(value) + } + + if (value < 1 && value > 0) { + return value.toFixed(2) + } + + return formatNumber(value, locale) +} + +function formatCurrency(amount: number, currency = 'USD', locale = 'en-US') { + return Intl.NumberFormat(locale, { + style: 'currency', + currency, + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }).format(amount) +} + +/** Formats a timestamp with a relative day label, e.g. "2026-05-19T14:35:10Z" -> "Today, 2:35:10 PM". */ +const formatDisplayTimestamp = (value: string | number | Date) => { + const date = new Date(value) + const now = new Date() + const yesterday = new Date() + yesterday.setDate(now.getDate() - 1) + + const isToday = date.toDateString() === now.toDateString() + const isYesterday = date.toDateString() === yesterday.toDateString() + const prefix = isToday + ? 'Today' + : isYesterday + ? 'Yesterday' + : date.toLocaleDateString('en-US') + const timeStr = date.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + second: '2-digit', + }) + + return `${prefix}, ${timeStr}` +} + +export { + formatAxisNumber, + formatCPUCores, + formatCurrency, + formatDecimal, + formatDisplayTimestamp, + formatMemory, + formatNumber, + pluralize, +} diff --git a/src/features/dashboard/timezone/date-time.ts b/src/lib/utils/formatting/time.ts similarity index 65% rename from src/features/dashboard/timezone/date-time.ts rename to src/lib/utils/formatting/time.ts index 1171c9ad7..92029c35e 100644 --- a/src/features/dashboard/timezone/date-time.ts +++ b/src/lib/utils/formatting/time.ts @@ -1,5 +1,7 @@ +import * as chrono from 'chrono-node' +import { format, isValid } from 'date-fns' import { formatInTimeZone, fromZonedTime } from 'date-fns-tz' -import type { Timezone } from './schema' +import type { Timezone } from '@/features/dashboard/timezone/schema' interface CalendarDateParts { year: number @@ -386,18 +388,234 @@ const getRelativeDay = ( return formatInTimeZone(value, timezone, 'PP') } +function formatChartTimestampLocal( + timestamp: number | string | Date, + showDate = false +): string { + const date = new Date(timestamp) + + if (showDate) { + return format(date, 'MMM d') + } + + return format(date, 'h:mm:ss a') +} + +function formatChartTimestampUTC( + timestamp: number | string | Date, + showDate = false +): string { + const date = new Date(timestamp) + + if (showDate) { + return formatInTimeZone(date, 'UTC', 'MMM d') + } + + return formatInTimeZone(date, 'UTC', 'h:mm:ss a') +} + +const formatRelativeAgo = (date: Date): string => { + const now = Date.now() + const timestamp = date.getTime() + const seconds = Math.floor((now - timestamp) / 1000) + if (seconds < 60) return `${seconds}s ago` + + const minutes = Math.floor(seconds / 60) + if (minutes < 60) return `${minutes} min ago` + + const hours = Math.floor(minutes / 60) + if (hours < 24) return `${hours}h ago` + + const days = Math.floor(hours / 24) + if (days < 7) return `${days}d ago` + + const weeks = Math.floor(days / 7) + if (weeks < 5) return `${weeks}w ago` + + if (days < 365) { + const months = Math.floor(days / 30) + return `${months}mo ago` + } + + const years = Math.floor(days / 365) + return `${years}y ago` +} + +function parseUTCDateComponents(date: string | Date) { + const dateTimeString = new Date(date).toUTCString() + const [day, dateStr, month, year, time, timezone] = dateTimeString.split(' ') + + return { + day, + date: dateStr, + month, + year, + time, + timezone, + full: dateTimeString, + } +} + +function formatTimeAxisLabel( + value: string | number, + showDate = false, + useLocal = true +): string { + const date = new Date(value) + + if (useLocal) { + return formatChartTimestampLocal(date, showDate) + } + + return formatChartTimestampUTC(date, showDate) +} + +function formatDuration(durationMs: number): string { + const seconds = Math.floor(durationMs / 1000) + + if (seconds < 60) { + return `${seconds} second${seconds !== 1 ? 's' : ''}` + } + if (seconds < 3600) { + const minutes = Math.floor(seconds / 60) + return `${minutes} minute${minutes !== 1 ? 's' : ''}` + } + + const hours = Math.floor(seconds / 3600) + return `${hours} hour${hours !== 1 ? 's' : ''}` +} + +function formatDurationCompact(ms: number, showDecimalSeconds = false): string { + const seconds = Math.floor(ms / 1000) + const minutes = Math.floor(seconds / 60) + const hours = Math.floor(minutes / 60) + + if (hours > 0) { + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m` + } + if (minutes > 0) { + const remainingSeconds = seconds % 60 + return `${minutes}m ${remainingSeconds}s` + } + return showDecimalSeconds + ? `${seconds}.${Math.floor((ms % 1000) / 100)}s` + : `${seconds}s` +} + +function formatTimeAgoCompact(ms: number): string { + const minutes = Math.floor(ms / 1000 / 60) + const hours = Math.floor(minutes / 60) + const days = Math.floor(hours / 24) + const months = Math.floor(days / 30) + + if (minutes < 1) { + return '< 1m ago' + } + if (hours < 1) { + return `${minutes}m ago` + } + if (days < 1) { + const remainingMinutes = minutes % 60 + return `${hours}h ${remainingMinutes}m ago` + } + if (months < 1) { + const remainingHours = hours % 24 + return `${days}d ${remainingHours}h ago` + } + + const remainingDays = days % 30 + return `${months}mo ${remainingDays}d ago` +} + +function formatAveragingPeriod(stepMs: number): string { + return `${formatDuration(stepMs)} average` +} + +function tryParseDatetime(input: string): Date | null { + if (!input.trim()) return null + + const timestamp = Number(input) + if (!Number.isNaN(timestamp)) { + const date = new Date( + timestamp < 10000000000 ? timestamp * 1000 : timestamp + ) + if (isValid(date)) return date + } + + try { + const parsedDate = chrono.parseDate(input) + return parsedDate || null + } catch { + return null + } +} + +function formatDateWithSpaces(date: Date | null): string { + if (!date) return '' + const day = String(date.getDate()).padStart(2, '0') + const month = String(date.getMonth() + 1).padStart(2, '0') + const year = date.getFullYear() + return `${day} / ${month} / ${year}` +} + +function formatTimeWithSpaces( + hours: string | number, + minutes: string | number, + seconds: string | number +): string { + const h = String(hours).padStart(2, '0') + const m = String(minutes).padStart(2, '0') + const s = String(seconds).padStart(2, '0') + return `${h} : ${m} : ${s}` +} + +function parseDateTimeComponents(dateTimeStr: string): { + date: string + time: string +} { + if (!dateTimeStr) return { date: '', time: '' } + const parsed = tryParseDatetime(dateTimeStr) + if (!parsed) return { date: '', time: '' } + + const year = parsed.getFullYear() + const month = String(parsed.getMonth() + 1).padStart(2, '0') + const day = String(parsed.getDate()).padStart(2, '0') + const hours = String(parsed.getHours()).padStart(2, '0') + const minutes = String(parsed.getMinutes()).padStart(2, '0') + const seconds = String(parsed.getSeconds()).padStart(2, '0') + + return { + date: `${year}/${month}/${day}`, + time: `${hours}:${minutes}:${seconds}`, + } +} + export { + dateTimePartsToUtcDate, + dateTimePartsToUtcTimestamp, + formatAveragingPeriod, + formatChartTimestampLocal, + formatChartTimestampUTC, formatDate, formatDateParts, formatDateRange, - formatTimezoneAbbreviation, formatDateTimeInput, - getRelativeDay, + formatDateWithSpaces, + formatDuration, + formatDurationCompact, + formatRelativeAgo, + formatTimeAgoCompact, + formatTimeAxisLabel, + formatTimeWithSpaces, + formatTimezoneAbbreviation, getDateParts, getDateTimeParts, - shiftCalendarDays, - dateTimePartsToUtcDate, - dateTimePartsToUtcTimestamp, + getRelativeDay, instantToCalendarDate, + parseDateTimeComponents, + parseUTCDateComponents, + shiftCalendarDays, + tryParseDatetime, } -export type { DateFormat, CalendarDateTimeParts } +export type { CalendarDateTimeParts, DateFormat } diff --git a/src/ui/time-input.tsx b/src/ui/time-input.tsx index d34204a0d..dfc692675 100644 --- a/src/ui/time-input.tsx +++ b/src/ui/time-input.tsx @@ -1,14 +1,12 @@ 'use client' import { memo, useCallback, useEffect, useState } from 'react' -import { - formatTimezoneAbbreviation, - useTimezone, -} from '@/features/dashboard/timezone' +import { useTimezone } from '@/features/dashboard/timezone' import { cn } from '@/lib/utils' import { formatDateWithSpaces, formatTimeWithSpaces, + formatTimezoneAbbreviation, tryParseDatetime, } from '@/lib/utils/formatting' import { NumberInput } from './number-input' diff --git a/src/ui/time-range-picker.logic.ts b/src/ui/time-range-picker.logic.ts index 92d8b9f6a..95c5fb128 100644 --- a/src/ui/time-range-picker.logic.ts +++ b/src/ui/time-range-picker.logic.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { dateTimePartsToUtcDate } from '@/features/dashboard/timezone/date-time' -import type { Timezone } from '@/features/dashboard/timezone/schema' +import type { Timezone } from '@/features/dashboard/timezone' import { getBrowserTimezone } from '@/features/dashboard/timezone/utils' +import { dateTimePartsToUtcDate } from '@/lib/utils/formatting' export interface TimeRangeValues { startDate: string diff --git a/src/ui/time-range-picker.tsx b/src/ui/time-range-picker.tsx index c92656ce8..d329bc6a7 100644 --- a/src/ui/time-range-picker.tsx +++ b/src/ui/time-range-picker.tsx @@ -3,13 +3,12 @@ import { zodResolver } from '@hookform/resolvers/zod' import { useCallback, useEffect, useMemo } from 'react' import { useForm } from 'react-hook-form' - +import { useTimezone } from '@/features/dashboard/timezone' +import { cn } from '@/lib/utils' import { formatDateTimeInput, instantToCalendarDate, - useTimezone, -} from '@/features/dashboard/timezone' -import { cn } from '@/lib/utils' +} from '@/lib/utils/formatting' import { Button } from './primitives/button' import { diff --git a/tests/unit/helpers/timezone.ts b/tests/unit/helpers/timezone.ts index a9ce536c3..ca9cd9f61 100644 --- a/tests/unit/helpers/timezone.ts +++ b/tests/unit/helpers/timezone.ts @@ -1,4 +1,5 @@ -import { parseTimezone, type Timezone } from '@/features/dashboard/timezone' +import type { Timezone } from '@/features/dashboard/timezone' +import { parseTimezone } from '@/features/dashboard/timezone/utils' const requireTimezone = (value: string): Timezone => { const timezone = parseTimezone(value) diff --git a/tests/unit/time-range-picker-logic.test.ts b/tests/unit/time-range-picker-logic.test.ts index 29de27be6..777142cc9 100644 --- a/tests/unit/time-range-picker-logic.test.ts +++ b/tests/unit/time-range-picker-logic.test.ts @@ -1,6 +1,6 @@ import { startOfDay } from 'date-fns' import { describe, expect, it, vi } from 'vitest' -import { instantToCalendarDate } from '@/features/dashboard/timezone' +import { instantToCalendarDate } from '@/lib/utils/formatting' import { createTimeRangeSchema, normalizeTimeRangeValues, diff --git a/tests/unit/timezone-date-time.test.ts b/tests/unit/timezone-date-time.test.ts index 430a50008..261a0b90f 100644 --- a/tests/unit/timezone-date-time.test.ts +++ b/tests/unit/timezone-date-time.test.ts @@ -10,7 +10,7 @@ import { formatTimezoneAbbreviation, getRelativeDay, instantToCalendarDate, -} from '@/features/dashboard/timezone' +} from '@/lib/utils/formatting' import { requireTimezone } from './helpers/timezone' describe('timezone date-time helpers', () => { diff --git a/tests/unit/usage-time-range-presets.test.ts b/tests/unit/usage-time-range-presets.test.ts index 411386cc4..463cbc9af 100644 --- a/tests/unit/usage-time-range-presets.test.ts +++ b/tests/unit/usage-time-range-presets.test.ts @@ -1,9 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { - formatDateTimeInput, - type Timezone, -} from '@/features/dashboard/timezone' +import type { Timezone } from '@/features/dashboard/timezone' import { getUsageTimeRangePresets } from '@/features/dashboard/usage/constants' +import { formatDateTimeInput } from '@/lib/utils/formatting' import { requireTimezone } from './helpers/timezone' const getPresetRange = (timezone: Timezone, presetId: string) => { From 215cf1bd65248317f7742223687596183b758e2c Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Mon, 15 Jun 2026 11:28:44 -0400 Subject: [PATCH 38/51] feat(timezone): add button to use browser timezone if not selected - Introduced a conditional button that allows users to select their browser's timezone if it differs from the currently selected timezone. - Enhanced accessibility by managing button visibility and interaction based on the browser timezone availability. --- .../dashboard/account/timezone-settings.tsx | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/features/dashboard/account/timezone-settings.tsx b/src/features/dashboard/account/timezone-settings.tsx index 9dcd52c96..1aa0578aa 100644 --- a/src/features/dashboard/account/timezone-settings.tsx +++ b/src/features/dashboard/account/timezone-settings.tsx @@ -75,6 +75,8 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { [browserTimezone] ) const isBrowserTimezoneSelected = timezone === browserTimezone + const showUseBrowserTimezoneButton = + Boolean(browserTimezone) && !isBrowserTimezoneSelected const handleTimezoneSelect = async (nextTimezone: Timezone) => { if (nextTimezone === timezone) { @@ -151,16 +153,22 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { Browser timezone:{' '} {browserTimezoneLabel}

- {browserTimezone && !isBrowserTimezoneSelected ? ( +
- ) : null} +
) From 1670863bf8a2ad9958f05eb95aea699650764fdf Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 16 Jun 2026 16:45:37 -0400 Subject: [PATCH 39/51] Keep timezone popover stable --- src/features/dashboard/account/timezone-settings.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/account/timezone-settings.tsx b/src/features/dashboard/account/timezone-settings.tsx index 1aa0578aa..51060efe6 100644 --- a/src/features/dashboard/account/timezone-settings.tsx +++ b/src/features/dashboard/account/timezone-settings.tsx @@ -122,8 +122,12 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { {timezoneLabel} - - + + No timezones found. From a927cfc62b63720855f7e78e6649c326c1dcefd2 Mon Sep 17 00:00:00 2001 From: Sarim Malik Date: Tue, 16 Jun 2026 16:56:06 -0400 Subject: [PATCH 40/51] Polish timezone save interaction --- .../dashboard/account/timezone-settings.tsx | 27 ++++++++++++++----- 1 file changed, 20 insertions(+), 7 deletions(-) diff --git a/src/features/dashboard/account/timezone-settings.tsx b/src/features/dashboard/account/timezone-settings.tsx index 51060efe6..82172bd6a 100644 --- a/src/features/dashboard/account/timezone-settings.tsx +++ b/src/features/dashboard/account/timezone-settings.tsx @@ -39,6 +39,8 @@ interface TimezoneSettingsProps { className?: string } +const TIMEZONE_SAVE_SETTLE_DELAY_MS = 300 + export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { const { timezone, setTimezone } = useTimezone() const { toast } = useToast() @@ -79,13 +81,20 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { 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) { @@ -94,7 +103,6 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { } toast(defaultSuccessToast('Timezone updated.')) - setOpen(false) } return ( @@ -111,12 +119,17 @@ export const TimezoneSettings = ({ className }: TimezoneSettingsProps) => { - + { + if (!isSaving) setOpen(nextOpen) + }} + >
) } + +export function LegacyStartedAtCell({ + getValue, +}: CellContext) { + const { timezone } = useTimezone() + const dateValue = (getValue() as string | undefined) ?? '' + + const formattedTimestamp = useMemo(() => { + return formatDateParts(dateValue, { + timezone, + format: 'date-time-no-seconds', + }) + }, [dateValue, timezone]) + + return ( +
+ + {formattedTimestamp?.datePart ?? '--'} + {' '} + {formattedTimestamp?.timePart ?? '--'}{' '} + + {formattedTimestamp?.timezonePart ?? ''} + +
+ ) +} diff --git a/src/features/dashboard/sandboxes/list/table-config.tsx b/src/features/dashboard/sandboxes/list/table-config.tsx index 706b85271..a217449b2 100644 --- a/src/features/dashboard/sandboxes/list/table-config.tsx +++ b/src/features/dashboard/sandboxes/list/table-config.tsx @@ -3,13 +3,17 @@ import { rankItem } from '@tanstack/match-sorter-utils' import type { ColumnDef, FilterFn, Table } from '@tanstack/react-table' import { isWithinInterval } from 'date-fns' +import { useMemo } from 'react' import type { DateRange } from 'react-day-picker' import type { Sandbox } from '@/core/modules/sandboxes/models' +import { getTimestampColumnSize } from '@/features/dashboard/common/timestamp-column' +import { useTimezone } from '@/features/dashboard/timezone' import { CpuUsageCell, DiskUsageCell, IdCell, + LegacyStartedAtCell, MetadataCell, RamUsageCell, StartedAtCell, @@ -196,83 +200,97 @@ export const sandboxListColumns: ColumnDef[] = [ }, ] -export const legacySandboxListColumns: ColumnDef[] = [ - { - accessorKey: 'sandboxID', - header: 'ID', - cell: IdCell, - size: 165, - minSize: 100, - enableResizing: false, - enableColumnFilter: false, - enableSorting: false, - enableGlobalFilter: true, - }, - { - accessorFn: (row) => row.alias || row.templateID, - id: 'template', - header: 'TEMPLATE', - cell: TemplateCell, - size: 250, - minSize: 100, - maxSize: 350, - enableResizing: true, - filterFn: templateIdentifierFilter, - enableGlobalFilter: false, - }, - { - id: 'cpuUsage', - header: 'CPU', - cell: (props) => , - size: 100, - enableResizing: false, - enableSorting: false, - enableColumnFilter: true, - filterFn: resourceEqualsFilter, - }, - { - id: 'ramUsage', - header: 'Memory', - cell: (props) => , - size: 140, - enableResizing: false, - enableSorting: false, - enableColumnFilter: true, - filterFn: resourceEqualsFilter, - }, - { - id: 'diskUsage', - header: 'Disk', - cell: (props) => , - size: 100, - enableResizing: false, - enableSorting: false, - enableColumnFilter: false, - }, - { - id: 'metadata', - accessorFn: (row) => JSON.stringify(row.metadata ?? {}), - header: 'Metadata', - cell: MetadataCell, - filterFn: 'includesStringSensitive', - enableGlobalFilter: false, - size: 200, - minSize: 160, - enableResizing: true, - enableSorting: false, - }, - { - id: 'startedAt', - accessorKey: 'startedAt', - header: 'Started At', - cell: StartedAtCell, - size: 150, - enableResizing: false, - filterFn: startedAtDateRangeFilter, - enableColumnFilter: true, - enableGlobalFilter: false, - sortingFn: (rowA, rowB) => { - return rowA.original.startedAt.localeCompare(rowB.original.startedAt) - }, - }, -] +// Templates' base (172) minus the ", 2026" year segment the legacy format drops. +const LEGACY_TIMESTAMP_COLUMN_BASE_SIZE = 124 + +export const useLegacySandboxListColumns = (): ColumnDef[] => { + const { timezone } = useTimezone() + const timestampColumnSize = getTimestampColumnSize( + timezone, + LEGACY_TIMESTAMP_COLUMN_BASE_SIZE + ) + + return useMemo( + () => [ + { + accessorKey: 'sandboxID', + header: 'ID', + cell: IdCell, + size: 165, + minSize: 100, + enableResizing: false, + enableColumnFilter: false, + enableSorting: false, + enableGlobalFilter: true, + }, + { + accessorFn: (row) => row.alias || row.templateID, + id: 'template', + header: 'TEMPLATE', + cell: TemplateCell, + size: 250, + minSize: 100, + maxSize: 350, + enableResizing: true, + filterFn: templateIdentifierFilter, + enableGlobalFilter: false, + }, + { + id: 'cpuUsage', + header: 'CPU', + cell: (props) => , + size: 100, + enableResizing: false, + enableSorting: false, + enableColumnFilter: true, + filterFn: resourceEqualsFilter, + }, + { + id: 'ramUsage', + header: 'Memory', + cell: (props) => , + size: 140, + enableResizing: false, + enableSorting: false, + enableColumnFilter: true, + filterFn: resourceEqualsFilter, + }, + { + id: 'diskUsage', + header: 'Disk', + cell: (props) => , + size: 100, + enableResizing: false, + enableSorting: false, + enableColumnFilter: false, + }, + { + id: 'metadata', + accessorFn: (row) => JSON.stringify(row.metadata ?? {}), + header: 'Metadata', + cell: MetadataCell, + filterFn: 'includesStringSensitive', + enableGlobalFilter: false, + size: 200, + minSize: 160, + enableResizing: true, + enableSorting: false, + }, + { + id: 'startedAt', + accessorKey: 'startedAt', + header: 'Started At', + cell: LegacyStartedAtCell, + size: timestampColumnSize, + enableResizing: false, + filterFn: startedAtDateRangeFilter, + enableColumnFilter: true, + enableGlobalFilter: false, + sortingFn: (rowA, rowB) => { + return rowA.original.startedAt.localeCompare(rowB.original.startedAt) + }, + }, + ], + [timestampColumnSize] + ) +} diff --git a/src/features/dashboard/sandboxes/list/table.tsx b/src/features/dashboard/sandboxes/list/table.tsx index 21c0e6e56..dfc8bd480 100644 --- a/src/features/dashboard/sandboxes/list/table.tsx +++ b/src/features/dashboard/sandboxes/list/table.tsx @@ -38,9 +38,9 @@ import { getSandboxListEffectiveSorting } from './stores/table-store' import { SandboxesTableBody } from './table-body' import type { SandboxListRow } from './table-config' import { - legacySandboxListColumns, sandboxIdFuzzyFilter, sandboxListColumns, + useLegacySandboxListColumns, } from './table-config' const STARTED_AT_FILTER_HOURS: Record< @@ -299,6 +299,7 @@ export function NewSandboxesTable() { export function LegacySandboxesTable() { const { teamSlug } = useRouteParams<'/dashboard/[teamSlug]/sandboxes'>() const trpc = useTRPC() + const legacySandboxListColumns = useLegacySandboxListColumns() const { data, refetch, isFetching } = useSuspenseQuery( trpc.sandboxes.getSandboxes.queryOptions( diff --git a/src/features/dashboard/templates/list/table-config.tsx b/src/features/dashboard/templates/list/table-config.tsx index 6909d1407..e51658fcf 100644 --- a/src/features/dashboard/templates/list/table-config.tsx +++ b/src/features/dashboard/templates/list/table-config.tsx @@ -8,6 +8,8 @@ import { import posthog from 'posthog-js' import { useMemo } from 'react' import type { DefaultTemplate, Template } from '@/core/modules/templates/models' +import { getTimestampColumnSize } from '@/features/dashboard/common/timestamp-column' +import { useTimezone } from '@/features/dashboard/timezone' import { ActionsCell, CreatedAtCell, @@ -30,7 +32,15 @@ export const trackTemplateTableInteraction = ( }) } +const TIMESTAMP_COLUMN_BASE_SIZE = 172 + export const useColumns = (deps: unknown[]) => { + const { timezone } = useTimezone() + const timestampColumnSize = getTimestampColumnSize( + timezone, + TIMESTAMP_COLUMN_BASE_SIZE + ) + return useMemo[]>( () => [ { @@ -57,7 +67,7 @@ export const useColumns = (deps: unknown[]) => { enableGlobalFilter: true, id: 'createdAt', header: 'Created', - size: 172, + size: timestampColumnSize, enableResizing: false, cell: CreatedAtCell, sortDescFirst: true, @@ -69,7 +79,7 @@ export const useColumns = (deps: unknown[]) => { accessorKey: 'updatedAt', id: 'updatedAt', header: 'Updated', - size: 172, + size: timestampColumnSize, enableGlobalFilter: true, enableResizing: false, cell: UpdatedAtCell, @@ -96,7 +106,7 @@ export const useColumns = (deps: unknown[]) => { cell: ActionsCell, }, ], - deps + [...deps, timestampColumnSize] ) } From 02f5f3d751babc6d2b11a9324509553653e7e840 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Thu, 9 Jul 2026 17:10:12 +0200 Subject: [PATCH 48/51] fix: add timezone header timestamps (sandbox, webhook) --- .../dashboard/sandbox/header/ended-at.tsx | 9 +++++-- .../dashboard/sandbox/header/started-at.tsx | 9 +++++-- src/features/dashboard/shared/timestamp.tsx | 21 ++++++++++++++-- src/lib/utils/formatting/index.ts | 24 ------------------- 4 files changed, 33 insertions(+), 30 deletions(-) diff --git a/src/features/dashboard/sandbox/header/ended-at.tsx b/src/features/dashboard/sandbox/header/ended-at.tsx index ac4903895..784aa9365 100644 --- a/src/features/dashboard/sandbox/header/ended-at.tsx +++ b/src/features/dashboard/sandbox/header/ended-at.tsx @@ -1,7 +1,11 @@ 'use client' import { useTimezone } from '@/features/dashboard/timezone' -import { formatDate, getRelativeDay } from '@/lib/utils/formatting' +import { + formatDate, + formatTimezoneAbbreviation, + getRelativeDay, +} from '@/lib/utils/formatting' import CopyButton from '@/ui/copy-button' import { useSandboxContext } from '../context' @@ -24,11 +28,12 @@ export default function EndedAt() { const relativeDay = getRelativeDay(endedAt, timezone) const timeStr = formatDate(endedAt, { timezone, format: 'time' }) + const tzAbbreviation = formatTimezoneAbbreviation(endedAt, timezone) return (

- {relativeDay}, {timeStr} + {relativeDay}, {timeStr} {tzAbbreviation}

- {relativeDay}, {timeStr} + {relativeDay}, {timeStr} {tzAbbreviation}

diff --git a/src/features/dashboard/shared/timestamp.tsx b/src/features/dashboard/shared/timestamp.tsx index aaca2bc1b..c06259136 100644 --- a/src/features/dashboard/shared/timestamp.tsx +++ b/src/features/dashboard/shared/timestamp.tsx @@ -1,9 +1,26 @@ -import { formatDisplayTimestamp } from '@/lib/utils/formatting' +'use client' + +import { useTimezone } from '@/features/dashboard/timezone' +import { + formatDate, + formatTimezoneAbbreviation, + getRelativeDay, +} from '@/lib/utils/formatting' type TimestampProps = { value: string } export const Timestamp = ({ value }: TimestampProps) => { - return

{formatDisplayTimestamp(value)}

+ const { timezone } = useTimezone() + + const relativeDay = getRelativeDay(value, timezone) + const timeStr = formatDate(value, { timezone, format: 'time' }) + const tzAbbreviation = formatTimezoneAbbreviation(value, timezone) + + return ( +

+ {relativeDay}, {timeStr} {tzAbbreviation} +

+ ) } diff --git a/src/lib/utils/formatting/index.ts b/src/lib/utils/formatting/index.ts index 37fa91a54..a010250f9 100644 --- a/src/lib/utils/formatting/index.ts +++ b/src/lib/utils/formatting/index.ts @@ -69,35 +69,11 @@ function formatCurrency(amount: number, currency = 'USD', locale = 'en-US') { }).format(amount) } -/** Formats a timestamp with a relative day label, e.g. "2026-05-19T14:35:10Z" -> "Today, 2:35:10 PM". */ -const formatDisplayTimestamp = (value: string | number | Date) => { - const date = new Date(value) - const now = new Date() - const yesterday = new Date() - yesterday.setDate(now.getDate() - 1) - - const isToday = date.toDateString() === now.toDateString() - const isYesterday = date.toDateString() === yesterday.toDateString() - const prefix = isToday - ? 'Today' - : isYesterday - ? 'Yesterday' - : date.toLocaleDateString('en-US') - const timeStr = date.toLocaleTimeString('en-US', { - hour: 'numeric', - minute: '2-digit', - second: '2-digit', - }) - - return `${prefix}, ${timeStr}` -} - export { formatAxisNumber, formatCPUCores, formatCurrency, formatDecimal, - formatDisplayTimestamp, formatMemory, formatNumber, pluralize, From fc45379c7cd0483e5d709a6703636f0cfc51896e Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Thu, 9 Jul 2026 17:28:11 +0200 Subject: [PATCH 49/51] fix: template overview timestamp --- .../templates/detail/overview/template-section.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/features/dashboard/templates/detail/overview/template-section.tsx b/src/features/dashboard/templates/detail/overview/template-section.tsx index 303937d0e..5441706d9 100644 --- a/src/features/dashboard/templates/detail/overview/template-section.tsx +++ b/src/features/dashboard/templates/detail/overview/template-section.tsx @@ -49,10 +49,14 @@ export function TemplateSection({ template, teamSlug }: TemplateSectionProps) {
- {created ? `${created.datePart} ${created.timePart}` : '--'} + {created + ? `${created.datePart}, ${created.timePart} ${created.timezonePart}` + : '--'} - {modified ? `${modified.datePart} ${modified.timePart}` : 'Not yet'} + {modified + ? `${modified.datePart}, ${modified.timePart} ${modified.timezonePart}` + : 'Not yet'} Date: Thu, 9 Jul 2026 17:48:30 +0200 Subject: [PATCH 50/51] chore: cleanup unused import --- src/features/dashboard/templates/list/table-cells.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/src/features/dashboard/templates/list/table-cells.tsx b/src/features/dashboard/templates/list/table-cells.tsx index 2140eee6b..6515fcffa 100644 --- a/src/features/dashboard/templates/list/table-cells.tsx +++ b/src/features/dashboard/templates/list/table-cells.tsx @@ -13,7 +13,6 @@ import { } from '@/lib/hooks/use-toast' import { cn } from '@/lib/utils' import { formatDateParts } from '@/lib/utils/formatting' -import { isVersionCompatible } from '@/lib/utils/version' import { useTRPC } from '@/trpc/client' import { AlertDialog } from '@/ui/alert-dialog' import { E2BBadge } from '@/ui/brand' From 297b6267549575ad853831587c2e7ed51f289572 Mon Sep 17 00:00:00 2001 From: Alex Drankou Date: Thu, 9 Jul 2026 18:17:00 +0200 Subject: [PATCH 51/51] fix: stale timezone after team switch --- src/app/dashboard/[teamSlug]/layout.tsx | 41 +++++++++------------ src/app/dashboard/layout.tsx | 19 ++++++++++ src/features/dashboard/timezone/context.tsx | 8 +++- 3 files changed, 43 insertions(+), 25 deletions(-) create mode 100644 src/app/dashboard/layout.tsx diff --git a/src/app/dashboard/[teamSlug]/layout.tsx b/src/app/dashboard/[teamSlug]/layout.tsx index 6febb403b..6e2e31b13 100644 --- a/src/app/dashboard/[teamSlug]/layout.tsx +++ b/src/app/dashboard/[teamSlug]/layout.tsx @@ -13,8 +13,6 @@ 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 { getQueryClient, HydrateClient, @@ -48,9 +46,6 @@ 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) @@ -94,26 +89,24 @@ export default async function DashboardLayout({ - - -
-
- - - - - {children} - - - -
+ +
+
+ + + + + {children} + + +
- - +
+
diff --git a/src/app/dashboard/layout.tsx b/src/app/dashboard/layout.tsx new file mode 100644 index 000000000..3ff151d0b --- /dev/null +++ b/src/app/dashboard/layout.tsx @@ -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 ( + {children} + ) +} diff --git a/src/features/dashboard/timezone/context.tsx b/src/features/dashboard/timezone/context.tsx index 497cb7319..8e39e8f14 100644 --- a/src/features/dashboard/timezone/context.tsx +++ b/src/features/dashboard/timezone/context.tsx @@ -1,5 +1,6 @@ 'use client' +import { useRouter } from 'next/navigation' import { createContext, type ReactNode, @@ -46,6 +47,7 @@ export const TimezoneProvider = ({ children, initialTimezone, }: TimezoneProviderProps) => { + const router = useRouter() const parsedInitialTimezone = useMemo( () => parseTimezone(initialTimezone), [initialTimezone] @@ -73,9 +75,13 @@ export const TimezoneProvider = ({ return false } + // route handlers setting cookies don't invalidate the client router + // cache, so purge it to keep server-rendered payloads in sync + router.refresh() + return true }, - [timezone] + [router, timezone] ) const value = useMemo(