From 1eda1ddb520bccd1c7b55b29471cc9a51ed775af Mon Sep 17 00:00:00 2001 From: Bucky Schwarz Date: Wed, 12 Feb 2025 09:14:23 -0500 Subject: [PATCH] Create DateDetails component (#460) --- package-lock.json | 7 + package.json | 1 + .../DateDetails/DateDetails.stories.tsx | 54 +++++ .../DateDetails/DateDetails.test.tsx | 115 ++++++++++ src/components/DateDetails/DateDetails.tsx | 199 ++++++++++++++++++ src/components/Popover/Popover.tsx | 7 +- src/components/index.ts | 9 +- vite.config.ts | 2 + 8 files changed, 387 insertions(+), 7 deletions(-) create mode 100644 src/components/DateDetails/DateDetails.stories.tsx create mode 100644 src/components/DateDetails/DateDetails.test.tsx create mode 100644 src/components/DateDetails/DateDetails.tsx diff --git a/package-lock.json b/package-lock.json index 2427b3a4..99b9c063 100644 --- a/package-lock.json +++ b/package-lock.json @@ -81,6 +81,7 @@ "watch": "^1.0.2" }, "peerDependencies": { + "dayjs": "^1.11.13", "react": "^18.2.0", "react-dom": "^18.2.0", "styled-components": ">= 5" @@ -9927,6 +9928,12 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/dayjs": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.13.tgz", + "integrity": "sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==", + "peer": true + }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", diff --git a/package.json b/package.json index 02893d5e..6e33d09a 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,7 @@ "watch": "^1.0.2" }, "peerDependencies": { + "dayjs": "^1.11.13", "react": "^18.2.0", "react-dom": "^18.2.0", "styled-components": ">= 5" diff --git a/src/components/DateDetails/DateDetails.stories.tsx b/src/components/DateDetails/DateDetails.stories.tsx new file mode 100644 index 00000000..5df2549b --- /dev/null +++ b/src/components/DateDetails/DateDetails.stories.tsx @@ -0,0 +1,54 @@ +import { Args } from "@storybook/react"; +import { DateDetails } from "./DateDetails"; + +export default { + argTypes: { + side: { + control: { + type: "select", + }, + options: ["top", "right", "left", "bottom"], + }, + date: { + control: "date", + }, + systemTimeZone: { + options: [ + "America/Denver", + "America/Los_Angeles", + "America/New_York", + "Asia/Shanghai", + "Asia/Tokyo", + "Europe/London", + "Europe/Berlin", + "Europe/Moscow", + "Europe/Rome", + ], + control: { + type: "select", + }, + }, + }, + component: DateDetails, + title: "Display/DateDetails", + tags: ["autodocs"], +}; + +export const Playground = { + args: { + date: new Date(), + side: "top", + systemTimeZone: "America/Los_Angeles", + title: "DateDetails", + }, + render: (args: Args) => { + const date = args.date ? new Date(args.date) : new Date(); + return ( + + ); + }, +}; diff --git a/src/components/DateDetails/DateDetails.test.tsx b/src/components/DateDetails/DateDetails.test.tsx new file mode 100644 index 00000000..8c337c47 --- /dev/null +++ b/src/components/DateDetails/DateDetails.test.tsx @@ -0,0 +1,115 @@ +import { DateDetails } from "@/components/DateDetails/DateDetails"; +import { renderCUI } from "@/utils/test-utils"; +import { fireEvent } from "@testing-library/dom"; + +describe("DateDetails", () => { + const actualTZ = process.env.TZ; + + beforeAll(() => { + global.ResizeObserver = vi.fn(() => { + return { + observe: vi.fn(), + unobserve: vi.fn(), + disconnect: vi.fn(), + }; + }); + + process.env.TZ = "America/New_York"; + }); + + afterAll(() => { + process.env.TZ = actualTZ; + }); + + it("renders the DateDetails component with relevant timezone information", () => { + const baseDate = new Date("2024-12-24 11:45:00 AM"); + const systemTimeZone = "America/Los_Angeles"; + vi.setSystemTime(baseDate); + + const fiveMinutesAgo = new Date("2024-12-24 11:40:00 AM"); + + const { getByText } = renderCUI( + + ); + + const trigger = getByText("5 minutes ago"); + expect(trigger).toBeInTheDocument(); + + fireEvent.click(trigger); + expect( + getByText(content => { + return content.includes("EST"); + }) + ).toBeInTheDocument(); + expect( + getByText(content => { + return content.includes("PST"); + }) + ).toBeInTheDocument(); + expect(getByText("Dec 24, 4:40 p.m.")).toBeInTheDocument(); + expect(getByText("Dec 24, 11:40 a.m. (EST)")).toBeInTheDocument(); + expect(getByText("Dec 24, 8:40 a.m. (PST)")).toBeInTheDocument(); + expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument(); + }); + + it("only shows the date if the previous date isn't in this year", () => { + const baseDate = new Date("2025-02-07 11:45:00 AM"); + const systemTimeZone = "America/Los_Angeles"; + vi.setSystemTime(baseDate); + + const oneYearAgo = new Date("2024-02-07 11:45:00 AM"); + + const { getByText } = renderCUI( + + ); + + const trigger = getByText("1 year ago"); + expect(trigger).toBeInTheDocument(); + + fireEvent.click(trigger); + expect(getByText("Feb 7, 2024, 4:45 p.m.")).toBeInTheDocument(); + expect(getByText("Feb 7, 2024, 11:45 a.m. (EST)")).toBeInTheDocument(); + expect(getByText("Feb 7, 2024, 8:45 a.m. (PST)")).toBeInTheDocument(); + expect(getByText(oneYearAgo.getTime() / 1000)).toBeInTheDocument(); + }); + + it("handles Daylight Savings Time", () => { + const baseDate = new Date("2024-07-04 11:45:00 AM"); + const systemTimeZone = "America/Los_Angeles"; + vi.setSystemTime(baseDate); + + const fiveMinutesAgo = new Date("2024-07-04 11:40:00 AM"); + + const { getByText } = renderCUI( + + ); + + const trigger = getByText("5 minutes ago"); + expect(trigger).toBeInTheDocument(); + + fireEvent.click(trigger); + expect( + getByText(content => { + return content.includes("EDT"); + }) + ).toBeInTheDocument(); + expect( + getByText(content => { + return content.includes("PDT"); + }) + ).toBeInTheDocument(); + expect(getByText("Jul 4, 3:40 p.m.")).toBeInTheDocument(); + expect(getByText("Jul 4, 11:40 a.m. (EDT)")).toBeInTheDocument(); + expect(getByText("Jul 4, 8:40 a.m. (PDT)")).toBeInTheDocument(); + expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument(); + }); +}); diff --git a/src/components/DateDetails/DateDetails.tsx b/src/components/DateDetails/DateDetails.tsx new file mode 100644 index 00000000..a00736ab --- /dev/null +++ b/src/components/DateDetails/DateDetails.tsx @@ -0,0 +1,199 @@ +import dayjs, { Dayjs } from "dayjs"; +import advancedFormat from "dayjs/plugin/advancedFormat"; +import duration from "dayjs/plugin/duration"; +import localizedFormat from "dayjs/plugin/localizedFormat"; +import relativeTime from "dayjs/plugin/relativeTime"; +import timezone from "dayjs/plugin/timezone"; +import updateLocale from "dayjs/plugin/updateLocale"; +import utc from "dayjs/plugin/utc"; +import { styled } from "styled-components"; + +import { Popover } from "@/components/Popover/Popover"; +import { Text } from "@/components/Typography/Text/Text"; +import { linkStyles, StyledLinkProps } from "@/components/Link/common"; +import { GridContainer } from "@/components/GridContainer/GridContainer"; +import { Container } from "@/components/Container/Container"; + +dayjs.extend(advancedFormat); +dayjs.extend(duration); +dayjs.extend(localizedFormat); +dayjs.extend(updateLocale); +dayjs.extend(utc); + +const thresholds = [ + { l: "s", r: 1, d: "second" }, + { l: "ss", r: 56, d: "second" }, + { l: "m", r: 90, d: "second" }, + { l: "mm", r: 55, d: "minute" }, + { l: "h", r: 90, d: "minute" }, + { l: "hh", r: 22, d: "hour" }, + { l: "d", r: 40, d: "hour" }, + { l: "dd", r: 31, d: "day" }, + { l: "M", r: 45, d: "day" }, + { l: "MM", r: 11, d: "month" }, + { l: "y", r: 17, d: "month" }, + { l: "yy", r: 2, d: "year" }, +]; + +dayjs.extend(relativeTime, { thresholds }); + +dayjs.updateLocale("en", { + relativeTime: { + future: "In %s", + past: "%s ago", + s: "a few seconds", + ss: "%d seconds", + m: "1 minute", + mm: "%d minutes", + h: "1 hour", + hh: "%d hours", + d: "1 day", + dd: "%d days", + w: "1 week", + ww: "%d weeks", + M: "1 month", + MM: "%d months", + y: "1 year", + yy: "%d years", + }, +}); + +const UnderlinedTrigger = styled(Popover.Trigger)` + ${linkStyles} +`; + +const formatDateDetails = (date: Dayjs, timezone?: string): string => { + const isCurrentYear = dayjs().year() === date.year(); + const formatForCurrentYear = "MMM D, h:mm a"; + const formatForPastYear = "MMM D, YYYY, h:mm a"; + + if (isCurrentYear) { + if (timezone) { + const dateWithTimezone = date.tz(timezone); + return dateWithTimezone + .format(formatForCurrentYear) + .replace("am", "a.m.") + .replace("pm", "p.m."); + } + + return date.format(formatForCurrentYear).replace("am", "a.m.").replace("pm", "p.m."); + } + + if (timezone) { + const dateWithTimezone = date.tz(timezone); + return dateWithTimezone + .format(formatForPastYear) + .replace("am", "a.m.") + .replace("pm", "p.m."); + } + return date.format(formatForPastYear).replace("am", "a.m.").replace("pm", "p.m."); +}; + +const formatTimezone = (date: Dayjs, timezone: string): string => { + return ( + new Intl.DateTimeFormat(undefined, { + timeZone: timezone, + timeZoneName: "short", + }) + .formatToParts(date.toDate()) + .find(part => part.type === "timeZoneName")?.value ?? date.format("z") + ); +}; + +export type ArrowPosition = "top" | "right" | "left" | "bottom"; + +export interface DateDetailsProps { + date: Date; + side?: ArrowPosition; + systemTimeZone?: string; +} + +export const DateDetails = ({ + date, + side = "top", + systemTimeZone = "America/New_York", +}: DateDetailsProps) => { + const dayjsDate = dayjs(date); + + let systemTime; + if (systemTimeZone) { + dayjs.extend(timezone); + try { + systemTime = dayjsDate.tz(systemTimeZone); + } catch { + systemTime = dayjsDate.tz("America/New_York"); + } + } + + return ( + + + {dayjs.utc(date).fromNow()} + + + + + Local + + + + {formatDateDetails(dayjsDate)} ( + {formatTimezone(dayjsDate, dayjs.tz.guess())}) + + + + {systemTime && ( + <> + + System + + + + + {formatDateDetails(systemTime, systemTimeZone)} ( + {formatTimezone(systemTime, systemTimeZone)}) + + + + )} + + + UTC + + + {formatDateDetails(dayjsDate.utc(), "UTC")} + + + + Unix + + + {Math.round(date.getTime() / 1000)} + + + + + ); +}; diff --git a/src/components/Popover/Popover.tsx b/src/components/Popover/Popover.tsx index 71d2a0c1..68887a7f 100644 --- a/src/components/Popover/Popover.tsx +++ b/src/components/Popover/Popover.tsx @@ -11,11 +11,12 @@ export const Popover = ({ children, ...props }: RadixPopover.PopoverProps) => { }; const Trigger = styled(RadixPopover.Trigger)` - width: fit-content; - font: inherit; - color: inherit; background: inherit; border: none; + color: inherit; + cursor: pointer; + font: inherit; + width: fit-content; `; interface TriggerProps extends RadixPopover.PopoverTriggerProps { anchor?: ReactNode; diff --git a/src/components/index.ts b/src/components/index.ts index 84933dbe..6f4ecf98 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -14,22 +14,23 @@ export { Badge } from "./Badge/Badge"; export { BigStat } from "./BigStat/BigStat"; export { ButtonGroup } from "./ButtonGroup/ButtonGroup"; export { Button } from "./Button/Button"; -export { CardSecondary } from "./CardSecondary/CardSecondary"; -export { CardPrimary } from "./CardPrimary/CardPrimary"; export { CardHorizontal } from "./CardHorizontal/CardHorizontal"; +export { CardPrimary } from "./CardPrimary/CardPrimary"; export { CardPromotion } from "./CardPromotion/CardPromotion"; +export { CardSecondary } from "./CardSecondary/CardSecondary"; export { Checkbox } from "./Checkbox/Checkbox"; export { CodeBlock } from "./CodeBlock/CodeBlock"; +export { ConfirmationDialog } from "./ConfirmationDialog/ConfirmationDialog"; +export { ContextMenu } from "./ContextMenu/ContextMenu"; export { Container } from "./Container/Container"; +export { DateDetails } from "@/components/DateDetails/DateDetails"; export { DatePicker } from "./DatePicker/DatePicker"; export { Dialog } from "./Dialog/Dialog"; -export { ConfirmationDialog } from "./ConfirmationDialog/ConfirmationDialog"; export { EllipsisContent } from "./EllipsisContent/EllipsisContent"; export { Flyout } from "./Flyout/Flyout"; export { FormContainer } from "./FormContainer/FormContainer"; export { GridContainer } from "./GridContainer/GridContainer"; export { InlineCodeBlock } from "./CodeBlock/InlineCodeBlock"; -export { ContextMenu } from "./ContextMenu/ContextMenu"; export { default as Flags } from "./icons/Flags"; export { Grid } from "./Grid/Grid"; export { HoverCard } from "./HoverCard/HoverCard"; diff --git a/vite.config.ts b/vite.config.ts index 938ae4de..6a3426d6 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -40,6 +40,7 @@ export default defineConfig({ rollupOptions: { // Add _all_ external dependencies here external: [ + "dayjs", "react", "react-dom", "styled-components", @@ -51,6 +52,7 @@ export default defineConfig({ ], output: { globals: { + dayjs: "dayjs", react: "React", "styled-components": "styled", "react-dom": "ReactDOM",