6563793132
## 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.
539 lines
16 KiB
TypeScript
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>
|
|
);
|
|
}
|