From 83556e950b03f2882f5dd2646ee2f27f6538ebea Mon Sep 17 00:00:00 2001 From: Xevion Date: Thu, 23 Oct 2025 09:58:09 -0500 Subject: [PATCH] feat: add global date format preferences with timezone support Implements a synchronized date format toggle across all timestamps with persistent user preferences. Timestamps now support three format views (relative, absolute, ISO) with tooltips and copy functionality. Adds automatic timezone detection and display. --- package.json | 3 ++ pnpm-lock.yaml | 27 +++++++++++ src/components/DynamicDate.tsx | 78 ++++++++++++++++++++++-------- src/contexts/DateFormatContext.tsx | 49 +++++++++++++++++++ src/pages/_app.tsx | 5 +- 5 files changed, 140 insertions(+), 22 deletions(-) create mode 100644 src/contexts/DateFormatContext.tsx 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" }} > - + + + );