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.
This commit is contained in:
2025-10-23 09:58:09 -05:00
parent 0f5e3ea56a
commit 83556e950b
5 changed files with 140 additions and 22 deletions

View File

@@ -27,10 +27,13 @@
"@fontsource/ibm-plex-mono": "^5.2.7", "@fontsource/ibm-plex-mono": "^5.2.7",
"@mantine/hooks": "^8.3.5", "@mantine/hooks": "^8.3.5",
"@radix-ui/react-icons": "^1.3.2", "@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-tooltip": "^1.2.8",
"@radix-ui/themes": "^3.2.1", "@radix-ui/themes": "^3.2.1",
"@swc/helpers": "^0.5.11", "@swc/helpers": "^0.5.11",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"date-fns": "^4.1.0", "date-fns": "^4.1.0",
"date-fns-tz": "^3.2.0",
"lucide-react": "^0.546.0",
"next": "^15.5.6", "next": "^15.5.6",
"next-themes": "^0.4.6", "next-themes": "^0.4.6",
"react": "19.2.0", "react": "19.2.0",

27
pnpm-lock.yaml generated
View File

@@ -20,6 +20,9 @@ importers:
'@radix-ui/react-icons': '@radix-ui/react-icons':
specifier: ^1.3.2 specifier: ^1.3.2
version: 1.3.2(react@19.2.0) 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': '@radix-ui/themes':
specifier: ^3.2.1 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) 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: date-fns:
specifier: ^4.1.0 specifier: ^4.1.0
version: 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: next:
specifier: ^15.5.6 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) 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==} resolution: {integrity: sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==}
engines: {node: '>= 0.4'} 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: date-fns@4.1.0:
resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==} resolution: {integrity: sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==}
@@ -3161,6 +3175,11 @@ packages:
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
hasBin: true 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: lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true hasBin: true
@@ -6233,6 +6252,10 @@ snapshots:
es-errors: 1.3.0 es-errors: 1.3.0
is-data-view: 1.0.2 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: {} date-fns@4.1.0: {}
debug@3.2.7: debug@3.2.7:
@@ -7194,6 +7217,10 @@ snapshots:
dependencies: dependencies:
js-tokens: 4.0.0 js-tokens: 4.0.0
lucide-react@0.546.0(react@19.2.0):
dependencies:
react: 19.2.0
lz-string@1.5.0: {} lz-string@1.5.0: {}
magic-string@0.30.19: magic-string@0.30.19:

View File

@@ -1,38 +1,74 @@
import type { FunctionComponent } from "react"; import type { FunctionComponent } from "react";
import { useBoolean } from "usehooks-ts"; import { useMemo } from "react";
import { format } from "date-fns"; import { format } from "date-fns";
import { formatInTimeZone } from "date-fns-tz";
import TimeAgo from "react-timeago"; 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 = { type DynamicDateProps = {
value: Date | number; value: Date | number;
absoluteFormat?: string; absoluteFormat?: string;
showTimezone?: boolean;
}; };
/** /**
* A component for a toggleable switch between the absolute & human-relative date. * A timestamp component with global format preferences.
* @param value The date to be displayed, the Date value, or * Features:
* @param absoluteFormat Optional - the date-fns format string to use for the absolute date rendering. * - 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<DynamicDateProps> = ({ value, absoluteFormat }) => { const DynamicDate: FunctionComponent<DynamicDateProps> = ({
const { value: showAbsolute, toggle: toggleFormat } = useBoolean(true); 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" ? <TimeAgo date={date} /> : absoluteWithTz;
const date = new Date(value);
return ( return (
<Button <Tooltip
variant="ghost" content={
size="1" <Box style={{ minWidth: "280px" }} as="span">
onClick={toggleFormat} <Flex align="center" justify="between" mb="2" as="span">
style={{ padding: 0, height: "auto" }} <Text size="1">
<strong>Relative:</strong> <TimeAgo date={date} />
</Text>
</Flex>
<Flex align="center" justify="between" mb="2" as="span">
<Text size="1">
<strong>Absolute:</strong> {absoluteWithTz}
</Text>
</Flex>
<Flex align="center" justify="between" as="span">
<Text size="1" style={{ wordBreak: "break-all" }}>
<strong>ISO:</strong> {isoString}
</Text>
</Flex>
</Box>
}
> >
<Text className="dashed" title={date.toISOString()} size="2"> <Button variant="ghost" size="1" onClick={toggleFormat} style={{ cursor: "pointer" }}>
{showAbsolute ? ( <Text size="2">{displayValue}</Text>
format(date, absoluteFormat ?? "LLL do, y HH:mm:ss xxx") </Button>
) : ( </Tooltip>
<TimeAgo date={date} />
)}
</Text>
</Button>
); );
}; };

View File

@@ -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<DateFormatContextType | undefined>(undefined);
const STORAGE_KEY = "global-date-format-preference";
export const DateFormatProvider: FunctionComponent<{ children: ReactNode }> = ({ children }) => {
const [format, setFormat] = useState<DateFormat>(() => {
// 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 (
<DateFormatContext.Provider value={{ format, toggleFormat }}>
{children}
</DateFormatContext.Provider>
);
};
export const useDateFormat = (): DateFormatContextType => {
const context = useContext(DateFormatContext);
if (context === undefined) {
throw new Error("useDateFormat must be used within a DateFormatProvider");
}
return context;
};

View File

@@ -7,6 +7,7 @@ import "@fontsource/ibm-plex-mono/400.css";
import "@radix-ui/themes/styles.css"; import "@radix-ui/themes/styles.css";
import "@/styles/globals.css"; import "@/styles/globals.css";
import { DateFormatProvider } from "@/contexts/DateFormatContext";
const MyApp: AppType = ({ Component, pageProps }) => { const MyApp: AppType = ({ Component, pageProps }) => {
return ( return (
@@ -17,7 +18,9 @@ const MyApp: AppType = ({ Component, pageProps }) => {
scriptProps={{ "data-cfasync": "false" }} scriptProps={{ "data-cfasync": "false" }}
> >
<Theme accentColor="indigo" grayColor="slate" radius="medium" scaling="100%"> <Theme accentColor="indigo" grayColor="slate" radius="medium" scaling="100%">
<Component {...pageProps} /> <DateFormatProvider>
<Component {...pageProps} />
</DateFormatProvider>
</Theme> </Theme>
</ThemeProvider> </ThemeProvider>
); );