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>
|
|
);
|
|
}
|