Files
triggerdotdev--trigger.dev/apps/webapp/app/components/primitives/DateTime.tsx
Matt Aitken 6563793132 fix(webapp): dashboard timezone display + preference persistence (#4104)
## Summary

Two related timezone bugs in the dashboard.

1. The date/time tooltip could show a UTC offset label that contradicted
the time it displayed. A viewer whose machine clock differs from their
saved timezone (or when a date falls in the other DST phase) would see
something like `Local (UTC +0)` next to a value that isn't at +0.
2. A user's timezone preference silently failed to save whenever their
browser reported a zone like `UTC`, `Etc/UTC`, or `Asia/Kolkata`,
leaving their timestamps stuck in a previously-saved timezone.

## Offset label

The "Local" row formatted its time using the viewer's configured
timezone but computed the `(UTC +n)` label from `new
Date().getTimezoneOffset()`, the browser's offset at the current moment.
Those are two independent sources, so they disagreed when the configured
timezone differed from the machine, and also when the displayed date was
in the opposite DST phase. The label is now derived from the same date
and timezone used to render the row (via `Intl.DateTimeFormat` with
`timeZoneName: "longOffset"`), so it always matches the displayed time.

## Preference persistence

`/resources/timezone` validated the incoming zone against
`Intl.supportedValuesOf("timeZone")`, which lists only canonical zone
ids. Browsers report zones that aren't in that list via
`resolvedOptions().timeZone`, notably `UTC` (and `Etc/UTC`,
`Asia/Kolkata`, `GMT`), so those requests returned 400 and the
preference was never stored. Validation now checks whether the runtime
can resolve the zone at all, which accepts every real zone and still
rejects invalid input.

Added unit tests for both.
2026-07-02 15:04:09 +00:00

539 lines
16 KiB
TypeScript

import { GlobeAltIcon, GlobeAmericasIcon } from "@heroicons/react/20/solid";
import { useRouteLoaderData } from "@remix-run/react";
import { formatDistanceToNow } from "date-fns";
import { Laptop } from "lucide-react";
import { memo, type ReactNode, useEffect, useMemo, useState, useSyncExternalStore } from "react";
import { CopyButton } from "./CopyButton";
import { useLocales } from "./LocaleProvider";
import { Paragraph } from "./Paragraph";
import { SimpleTooltip } from "./Tooltip";
// Cache the browser's local timezone - resolved once and reused
let cachedLocalTimeZone: string | null = null;
function getLocalTimeZone(): string {
if (cachedLocalTimeZone === null) {
cachedLocalTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
}
return cachedLocalTimeZone;
}
// For SSR compatibility: returns "UTC" on server, actual timezone on client
function subscribeToTimeZone() {
// No-op - timezone doesn't change
return () => {};
}
function getTimeZoneSnapshot(): string {
return getLocalTimeZone();
}
function getServerTimeZoneSnapshot(): string {
return "UTC";
}
/**
* Hook to get the browser's local timezone.
* Uses useSyncExternalStore for SSR compatibility - returns "UTC" on server,
* actual timezone on client. The timezone is cached and only resolved once.
*/
export function useLocalTimeZone(): string {
return useSyncExternalStore(subscribeToTimeZone, getTimeZoneSnapshot, getServerTimeZoneSnapshot);
}
/**
* Hook to get the user's preferred timezone.
* Returns the timezone stored in the user's preferences cookie (from root loader),
* falling back to the browser's local timezone if not set.
*/
export function useUserTimeZone(): string {
const rootData = useRouteLoaderData("root") as { timezone?: string } | undefined;
const localTimeZone = useLocalTimeZone();
// Use stored timezone from cookie, or fall back to browser's local timezone
return rootData?.timezone && rootData.timezone !== "UTC" ? rootData.timezone : localTimeZone;
}
type DateTimeProps = {
date: Date | string;
timeZone?: string;
includeSeconds?: boolean;
includeTime?: boolean;
includeDate?: boolean;
showTimezone?: boolean;
showTooltip?: boolean;
hideDate?: boolean;
previousDate?: Date | string | null; // Add optional previous date for comparison
hour12?: boolean;
};
export const DateTime = ({
date,
timeZone,
includeSeconds = true,
includeTime = true,
includeDate = true,
showTimezone = false,
showTooltip = true,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
const formattedDateTime = (
<span suppressHydrationWarning>
{formatDateTime(
realDate,
timeZone ?? userTimeZone,
locales,
includeSeconds,
includeTime,
includeDate,
hour12
).replace(/\s/g, String.fromCharCode(32))}
{showTimezone ? ` (${timeZone ?? "UTC"})` : null}
</span>
);
if (!showTooltip) return formattedDateTime;
return (
<SimpleTooltip
button={formattedDateTime}
content={
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
}
side="right"
asChild={true}
/>
);
};
export function formatDateTime(
date: Date,
timeZone: string,
locales: string[],
includeSeconds: boolean,
includeTime: boolean,
includeDate: boolean = true,
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
year: includeDate ? "numeric" : undefined,
month: includeDate ? "short" : undefined,
day: includeDate ? "numeric" : undefined,
hour: includeTime ? "numeric" : undefined,
minute: includeTime ? "numeric" : undefined,
second: includeTime && includeSeconds ? "numeric" : undefined,
timeZone,
hour12,
}).format(date);
}
export function formatDateTimeISO(date: Date, timeZone: string): string {
// Special handling for UTC
if (timeZone === "UTC") {
return date.toISOString();
}
// Get the date parts in the target timezone
const dateFormatter = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false,
});
// Get the timezone offset for this specific date
const timeZoneFormatter = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "longOffset",
});
const dateParts = Object.fromEntries(
dateFormatter.formatToParts(date).map(({ type, value }) => [type, value])
);
const timeZoneParts = timeZoneFormatter.formatToParts(date);
const offset =
timeZoneParts.find((part) => part.type === "timeZoneName")?.value.replace("GMT", "") ||
"+00:00";
// Format: YYYY-MM-DDThh:mm:ss.sss±hh:mm
return (
`${dateParts.year}-${dateParts.month}-${dateParts.day}T` +
`${dateParts.hour}:${dateParts.minute}:${dateParts.second}.${String(
date.getMilliseconds()
).padStart(3, "0")}${offset}`
);
}
/**
* Human-readable UTC offset for a timezone at a specific instant, e.g. "(UTC +3)".
* Returns "" for UTC. The offset must be derived from the same (date, timeZone) pair
* used to render the displayed time so the label always matches the value shown — and
* so it stays correct across DST boundaries regardless of the viewer's current season.
*/
export function formatUtcOffset(date: Date, timeZone: string): string {
if (timeZone === "UTC") return "";
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
timeZoneName: "longOffset",
}).formatToParts(date);
// longOffset yields "GMT+03:00", "GMT-08:00", "GMT+05:30", or "GMT" for UTC-equivalent zones.
const raw = parts.find((part) => part.type === "timeZoneName")?.value.replace("GMT", "") ?? "";
const match = raw.match(/^([+-])(\d{2}):(\d{2})$/);
if (!match) return "(UTC +0)";
const [, sign, hh, mm] = match;
const hours = parseInt(hh, 10);
const minutes = parseInt(mm, 10);
return `(UTC ${sign}${hours}${minutes ? `:${minutes.toString().padStart(2, "0")}` : ""})`;
}
// New component that only shows date when it changes
export const SmartDateTime = ({ date, previousDate = null, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
? new Date(previousDate)
: previousDate
: null;
// Check if we should show the date
const showDatePart = !realPrevDate || !isSameDay(realDate, realPrevDate);
// Format with appropriate function
const formattedDateTime = showDatePart
? formatSmartDateTime(realDate, userTimeZone, locales, hour12)
: formatTimeOnly(realDate, userTimeZone, locales, hour12);
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
// Helper function to check if two dates are on the same day
function isSameDay(date1: Date, date2: Date): boolean {
return (
date1.getFullYear() === date2.getFullYear() &&
date1.getMonth() === date2.getMonth() &&
date1.getDate() === date2.getDate()
);
}
// Format with date and time
function formatSmartDateTime(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
}
// Format time only
function formatTimeOnly(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
return new Intl.DateTimeFormat(locales, {
hour: "2-digit",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
}
const DateTimeAccurateInner = ({
date,
timeZone,
previousDate = null,
showTooltip = true,
hideDate = false,
hour12 = true,
}: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
// Use provided timeZone prop if available, otherwise fall back to user's preferred timezone
const displayTimeZone = timeZone ?? userTimeZone;
const realDate = typeof date === "string" ? new Date(date) : date;
const realPrevDate = previousDate
? typeof previousDate === "string"
? new Date(previousDate)
: previousDate
: null;
// Smart formatting based on whether date changed
const formattedDateTime = useMemo(() => {
return hideDate
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: realPrevDate
? isSameDay(realDate, realPrevDate)
? formatTimeOnly(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12)
: formatDateTimeAccurate(realDate, displayTimeZone, locales, hour12);
}, [realDate, displayTimeZone, locales, hour12, hideDate, previousDate]);
if (!showTooltip)
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
const tooltipContent = (
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
);
return (
<SimpleTooltip
button={
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
}
content={tooltipContent}
side="right"
asChild={true}
/>
);
};
function areDateTimePropsEqual(prev: DateTimeProps, next: DateTimeProps): boolean {
// Compare Date objects by timestamp value, not reference
const prevTime = prev.date instanceof Date ? prev.date.getTime() : prev.date;
const nextTime = next.date instanceof Date ? next.date.getTime() : next.date;
if (prevTime !== nextTime) return false;
const prevPrevTime =
prev.previousDate instanceof Date ? prev.previousDate.getTime() : prev.previousDate;
const nextPrevTime =
next.previousDate instanceof Date ? next.previousDate.getTime() : next.previousDate;
if (prevPrevTime !== nextPrevTime) return false;
return (
prev.timeZone === next.timeZone &&
prev.showTooltip === next.showTooltip &&
prev.hideDate === next.hideDate &&
prev.hour12 === next.hour12
);
}
export const DateTimeAccurate = memo(DateTimeAccurateInner, areDateTimePropsEqual);
function formatDateTimeAccurate(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
const datePart = new Intl.DateTimeFormat(locales, {
month: "short",
day: "numeric",
timeZone,
}).format(date);
const timePart = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
return `${datePart} ${timePart}`;
}
type RelativeDateTimeProps = {
date: Date | string;
timeZone?: string;
capitalize?: boolean;
};
function getRelativeText(date: Date, capitalize = true): string {
const text = formatDistanceToNow(date, { addSuffix: true });
if (!capitalize) return text;
return text.charAt(0).toUpperCase() + text.slice(1);
}
export const RelativeDateTime = ({ date, timeZone, capitalize = true }: RelativeDateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = useMemo(() => (typeof date === "string" ? new Date(date) : date), [date]);
const [relativeText, setRelativeText] = useState(() => getRelativeText(realDate, capitalize));
// Every 60s refresh
useEffect(() => {
const interval = setInterval(() => {
setRelativeText(getRelativeText(realDate, capitalize));
}, 60_000);
return () => clearInterval(interval);
}, [realDate, capitalize]);
// On first render
useEffect(() => {
setRelativeText(getRelativeText(realDate, capitalize));
}, [realDate, capitalize]);
return (
<SimpleTooltip
button={<span suppressHydrationWarning>{relativeText}</span>}
content={
<TooltipContent
realDate={realDate}
timeZone={timeZone}
localTimeZone={userTimeZone}
locales={locales}
/>
}
side="right"
asChild={true}
/>
);
};
export const DateTimeShort = ({ date, hour12 = true }: DateTimeProps) => {
const locales = useLocales();
const userTimeZone = useUserTimeZone();
const realDate = typeof date === "string" ? new Date(date) : date;
const formattedDateTime = formatDateTimeShort(realDate, userTimeZone, locales, hour12);
return (
<span suppressHydrationWarning>
{formattedDateTime.replace(/\s/g, String.fromCharCode(32))}
</span>
);
};
function formatDateTimeShort(
date: Date,
timeZone: string,
locales: string[],
hour12: boolean = true
): string {
const formattedDateTime = new Intl.DateTimeFormat(locales, {
hour: "numeric",
minute: "numeric",
second: "numeric",
timeZone,
// @ts-ignore fractionalSecondDigits works in most modern browsers
fractionalSecondDigits: 3,
hour12,
}).format(date);
return formattedDateTime;
}
type DateTimeTooltipContentProps = {
title: string;
dateTime: string;
isoDateTime: string;
icon: ReactNode;
offset?: string;
};
function DateTimeTooltipContent({
title,
dateTime,
isoDateTime,
icon,
offset,
}: DateTimeTooltipContentProps) {
return (
<div className="flex flex-col gap-1">
<div className="flex items-center gap-1 text-sm">
{icon}
<span className="font-medium">{title}</span>
{offset ? <span className="font-normal text-text-dimmed">{offset}</span> : null}
</div>
<div className="flex items-center justify-between gap-2">
<Paragraph variant="extra-small" className="text-text-dimmed">
{dateTime}
</Paragraph>
<CopyButton value={isoDateTime} variant="icon" size="extra-small" showTooltip={false} />
</div>
</div>
);
}
function TooltipContent({
realDate,
timeZone,
localTimeZone,
locales,
}: {
realDate: Date;
timeZone?: string;
localTimeZone: string;
locales: string[];
}) {
return (
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-2.5 pb-1">
{timeZone && timeZone !== "UTC" && (
<DateTimeTooltipContent
title={timeZone}
dateTime={formatDateTime(realDate, timeZone, locales, true, true, true)}
isoDateTime={formatDateTimeISO(realDate, timeZone)}
icon={<GlobeAmericasIcon className="size-4 text-purple-500" />}
/>
)}
<DateTimeTooltipContent
title="UTC"
dateTime={formatDateTime(realDate, "UTC", locales, true, true, true)}
isoDateTime={formatDateTimeISO(realDate, "UTC")}
icon={<GlobeAltIcon className="size-4 text-blue-500" />}
/>
<DateTimeTooltipContent
title="Local"
dateTime={formatDateTime(realDate, localTimeZone, locales, true, true, true)}
isoDateTime={formatDateTimeISO(realDate, localTimeZone)}
icon={<Laptop className="size-4 text-green-500" />}
offset={formatUtcOffset(realDate, localTimeZone)}
/>
</div>
</div>
);
}