chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,538 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user