diff --git a/package.json b/package.json index c5e71f7..fbfea5c 100644 --- a/package.json +++ b/package.json @@ -27,10 +27,13 @@ "@fontsource/ibm-plex-mono": "^5.2.7", "@mantine/hooks": "^8.3.5", "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/themes": "^3.2.1", "@swc/helpers": "^0.5.11", "clsx": "^2.1.1", "date-fns": "^4.1.0", + "date-fns-tz": "^3.2.0", + "lucide-react": "^0.546.0", "next": "^15.5.6", "next-themes": "^0.4.6", "react": "19.2.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4fd7df7..5309df4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -20,6 +20,9 @@ importers: '@radix-ui/react-icons': specifier: ^1.3.2 version: 1.3.2(react@19.2.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.8 + version: 1.2.8(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) '@radix-ui/themes': specifier: ^3.2.1 version: 3.2.1(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) @@ -32,6 +35,12 @@ importers: date-fns: specifier: ^4.1.0 version: 4.1.0 + date-fns-tz: + specifier: ^3.2.0 + version: 3.2.0(date-fns@4.1.0) + lucide-react: + specifier: ^0.546.0 + version: 0.546.0(react@19.2.0) next: specifier: ^15.5.6 version: 15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0)(sass@1.93.2) @@ -2324,6 +2333,11 @@ packages: resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==} engines: {node: '>= 0.4'} + date-fns-tz@3.2.0: + resolution: {integrity: sha512-sg8HqoTEulcbbbVXeg84u5UnlsQa8GS5QXMqjjYIhS4abEVVKIUwe0/l/UhrZdKaL/W5eWZNlbTeEIiOXTcsBQ==} + peerDependencies: + date-fns: ^3.0.0 || ^4.0.0 + date-fns@4.1.0: resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} @@ -3161,6 +3175,11 @@ packages: resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} hasBin: true + lucide-react@0.546.0: + resolution: {integrity: sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lz-string@1.5.0: resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} hasBin: true @@ -6233,6 +6252,10 @@ snapshots: es-errors: 1.3.0 is-data-view: 1.0.2 + date-fns-tz@3.2.0(date-fns@4.1.0): + dependencies: + date-fns: 4.1.0 + date-fns@4.1.0: {} debug@3.2.7: @@ -7194,6 +7217,10 @@ snapshots: dependencies: js-tokens: 4.0.0 + lucide-react@0.546.0(react@19.2.0): + dependencies: + react: 19.2.0 + lz-string@1.5.0: {} magic-string@0.30.19: diff --git a/src/components/DynamicDate.tsx b/src/components/DynamicDate.tsx index 7aa64b6..a728f65 100644 --- a/src/components/DynamicDate.tsx +++ b/src/components/DynamicDate.tsx @@ -1,38 +1,74 @@ import type { FunctionComponent } from "react"; -import { useBoolean } from "usehooks-ts"; +import { useMemo } from "react"; import { format } from "date-fns"; +import { formatInTimeZone } from "date-fns-tz"; import TimeAgo from "react-timeago"; -import { Button, Text } from "@radix-ui/themes"; +import { Box, Button, Tooltip, Text, Flex } from "@radix-ui/themes"; +import { useDateFormat } from "@/contexts/DateFormatContext"; type DynamicDateProps = { value: Date | number; absoluteFormat?: string; + showTimezone?: boolean; }; /** - * A component for a toggleable switch between the absolute & human-relative date. - * @param value The date to be displayed, the Date value, or - * @param absoluteFormat Optional - the date-fns format string to use for the absolute date rendering. + * A timestamp component with global format preferences. + * Features: + * - Toggle between relative and absolute time formats + * - Global state - clicking any timestamp toggles all timestamps on the page + * - Tooltip shows all formats (relative, absolute, ISO) with copy buttons + * - Timezone support with automatic detection + * - Minimal, clean design */ -const DynamicDate: FunctionComponent = ({ value, absoluteFormat }) => { - const { value: showAbsolute, toggle: toggleFormat } = useBoolean(true); +const DynamicDate: FunctionComponent = ({ + value, + absoluteFormat, + showTimezone = true, +}) => { + const { format: dateFormat, toggleFormat } = useDateFormat(); + + // Get user timezone + const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone; + + const date = useMemo(() => new Date(value), [value]); + + // Format variants + const absoluteFormatString = absoluteFormat ?? "PPP 'at' p"; + const absoluteWithTz = showTimezone + ? formatInTimeZone(date, userTimezone, `${absoluteFormatString} (zzz)`) + : format(date, absoluteFormatString); + const isoString = date.toISOString(); + + // Get display value based on global format + const displayValue = dateFormat === "relative" ? : absoluteWithTz; - const date = new Date(value); return ( - + + ); }; diff --git a/src/contexts/DateFormatContext.tsx b/src/contexts/DateFormatContext.tsx new file mode 100644 index 0000000..5dd3fcd --- /dev/null +++ b/src/contexts/DateFormatContext.tsx @@ -0,0 +1,49 @@ +import type { FunctionComponent, ReactNode } from "react"; +import { createContext, useContext, useState, useEffect, useCallback } from "react"; + +type DateFormat = "relative" | "absolute"; + +type DateFormatContextType = { + format: DateFormat; + toggleFormat: () => void; +}; + +const DateFormatContext = createContext(undefined); + +const STORAGE_KEY = "global-date-format-preference"; + +export const DateFormatProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => { + const [format, setFormat] = useState(() => { + // Initialize from localStorage on client side + if (typeof window !== "undefined") { + const saved = localStorage.getItem(STORAGE_KEY); + return (saved as DateFormat) || "relative"; + } + return "relative"; + }); + + // Persist to localStorage whenever format changes + useEffect(() => { + if (typeof window !== "undefined") { + localStorage.setItem(STORAGE_KEY, format); + } + }, [format]); + + const toggleFormat = useCallback(() => { + setFormat((current) => (current === "relative" ? "absolute" : "relative")); + }, []); + + return ( + + {children} + + ); +}; + +export const useDateFormat = (): DateFormatContextType => { + const context = useContext(DateFormatContext); + if (context === undefined) { + throw new Error("useDateFormat must be used within a DateFormatProvider"); + } + return context; +}; diff --git a/src/pages/_app.tsx b/src/pages/_app.tsx index 206827b..ffe8a21 100644 --- a/src/pages/_app.tsx +++ b/src/pages/_app.tsx @@ -7,6 +7,7 @@ import "@fontsource/ibm-plex-mono/400.css"; import "@radix-ui/themes/styles.css"; import "@/styles/globals.css"; +import { DateFormatProvider } from "@/contexts/DateFormatContext"; const MyApp: AppType = ({ Component, pageProps }) => { return ( @@ -17,7 +18,9 @@ const MyApp: AppType = ({ Component, pageProps }) => { scriptProps={{ "data-cfasync": "false" }} > - + + + );