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 = ( {formatDateTime( realDate, timeZone ?? userTimeZone, locales, includeSeconds, includeTime, includeDate, hour12 ).replace(/\s/g, String.fromCharCode(32))} {showTimezone ? ` (${timeZone ?? "UTC"})` : null} ); if (!showTooltip) return formattedDateTime; return ( } 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 ( {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} ); }; // 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 ( {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} ); const tooltipContent = ( ); return ( {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} } 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 ( {relativeText}} content={ } 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 ( {formattedDateTime.replace(/\s/g, String.fromCharCode(32))} ); }; 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 (
{icon} {title} {offset ? {offset} : null}
{dateTime}
); } function TooltipContent({ realDate, timeZone, localTimeZone, locales, }: { realDate: Date; timeZone?: string; localTimeZone: string; locales: string[]; }) { return (
{timeZone && timeZone !== "UTC" && ( } /> )} } /> } offset={formatUtcOffset(realDate, localTimeZone)} />
); }