Skip to content

Commit 1eda1dd

Browse files
Create DateDetails component (#460)
1 parent 302e179 commit 1eda1dd

File tree

8 files changed

+387
-7
lines changed

8 files changed

+387
-7
lines changed

Diff for: package-lock.json

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: package.json

+1
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,7 @@
115115
"watch": "^1.0.2"
116116
},
117117
"peerDependencies": {
118+
"dayjs": "^1.11.13",
118119
"react": "^18.2.0",
119120
"react-dom": "^18.2.0",
120121
"styled-components": ">= 5"

Diff for: src/components/DateDetails/DateDetails.stories.tsx

+54
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Args } from "@storybook/react";
2+
import { DateDetails } from "./DateDetails";
3+
4+
export default {
5+
argTypes: {
6+
side: {
7+
control: {
8+
type: "select",
9+
},
10+
options: ["top", "right", "left", "bottom"],
11+
},
12+
date: {
13+
control: "date",
14+
},
15+
systemTimeZone: {
16+
options: [
17+
"America/Denver",
18+
"America/Los_Angeles",
19+
"America/New_York",
20+
"Asia/Shanghai",
21+
"Asia/Tokyo",
22+
"Europe/London",
23+
"Europe/Berlin",
24+
"Europe/Moscow",
25+
"Europe/Rome",
26+
],
27+
control: {
28+
type: "select",
29+
},
30+
},
31+
},
32+
component: DateDetails,
33+
title: "Display/DateDetails",
34+
tags: ["autodocs"],
35+
};
36+
37+
export const Playground = {
38+
args: {
39+
date: new Date(),
40+
side: "top",
41+
systemTimeZone: "America/Los_Angeles",
42+
title: "DateDetails",
43+
},
44+
render: (args: Args) => {
45+
const date = args.date ? new Date(args.date) : new Date();
46+
return (
47+
<DateDetails
48+
date={date}
49+
side={args.side}
50+
systemTimeZone={args.systemTimeZone}
51+
/>
52+
);
53+
},
54+
};

Diff for: src/components/DateDetails/DateDetails.test.tsx

+115
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { DateDetails } from "@/components/DateDetails/DateDetails";
2+
import { renderCUI } from "@/utils/test-utils";
3+
import { fireEvent } from "@testing-library/dom";
4+
5+
describe("DateDetails", () => {
6+
const actualTZ = process.env.TZ;
7+
8+
beforeAll(() => {
9+
global.ResizeObserver = vi.fn(() => {
10+
return {
11+
observe: vi.fn(),
12+
unobserve: vi.fn(),
13+
disconnect: vi.fn(),
14+
};
15+
});
16+
17+
process.env.TZ = "America/New_York";
18+
});
19+
20+
afterAll(() => {
21+
process.env.TZ = actualTZ;
22+
});
23+
24+
it("renders the DateDetails component with relevant timezone information", () => {
25+
const baseDate = new Date("2024-12-24 11:45:00 AM");
26+
const systemTimeZone = "America/Los_Angeles";
27+
vi.setSystemTime(baseDate);
28+
29+
const fiveMinutesAgo = new Date("2024-12-24 11:40:00 AM");
30+
31+
const { getByText } = renderCUI(
32+
<DateDetails
33+
date={fiveMinutesAgo}
34+
systemTimeZone={systemTimeZone}
35+
/>
36+
);
37+
38+
const trigger = getByText("5 minutes ago");
39+
expect(trigger).toBeInTheDocument();
40+
41+
fireEvent.click(trigger);
42+
expect(
43+
getByText(content => {
44+
return content.includes("EST");
45+
})
46+
).toBeInTheDocument();
47+
expect(
48+
getByText(content => {
49+
return content.includes("PST");
50+
})
51+
).toBeInTheDocument();
52+
expect(getByText("Dec 24, 4:40 p.m.")).toBeInTheDocument();
53+
expect(getByText("Dec 24, 11:40 a.m. (EST)")).toBeInTheDocument();
54+
expect(getByText("Dec 24, 8:40 a.m. (PST)")).toBeInTheDocument();
55+
expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument();
56+
});
57+
58+
it("only shows the date if the previous date isn't in this year", () => {
59+
const baseDate = new Date("2025-02-07 11:45:00 AM");
60+
const systemTimeZone = "America/Los_Angeles";
61+
vi.setSystemTime(baseDate);
62+
63+
const oneYearAgo = new Date("2024-02-07 11:45:00 AM");
64+
65+
const { getByText } = renderCUI(
66+
<DateDetails
67+
date={oneYearAgo}
68+
systemTimeZone={systemTimeZone}
69+
/>
70+
);
71+
72+
const trigger = getByText("1 year ago");
73+
expect(trigger).toBeInTheDocument();
74+
75+
fireEvent.click(trigger);
76+
expect(getByText("Feb 7, 2024, 4:45 p.m.")).toBeInTheDocument();
77+
expect(getByText("Feb 7, 2024, 11:45 a.m. (EST)")).toBeInTheDocument();
78+
expect(getByText("Feb 7, 2024, 8:45 a.m. (PST)")).toBeInTheDocument();
79+
expect(getByText(oneYearAgo.getTime() / 1000)).toBeInTheDocument();
80+
});
81+
82+
it("handles Daylight Savings Time", () => {
83+
const baseDate = new Date("2024-07-04 11:45:00 AM");
84+
const systemTimeZone = "America/Los_Angeles";
85+
vi.setSystemTime(baseDate);
86+
87+
const fiveMinutesAgo = new Date("2024-07-04 11:40:00 AM");
88+
89+
const { getByText } = renderCUI(
90+
<DateDetails
91+
date={fiveMinutesAgo}
92+
systemTimeZone={systemTimeZone}
93+
/>
94+
);
95+
96+
const trigger = getByText("5 minutes ago");
97+
expect(trigger).toBeInTheDocument();
98+
99+
fireEvent.click(trigger);
100+
expect(
101+
getByText(content => {
102+
return content.includes("EDT");
103+
})
104+
).toBeInTheDocument();
105+
expect(
106+
getByText(content => {
107+
return content.includes("PDT");
108+
})
109+
).toBeInTheDocument();
110+
expect(getByText("Jul 4, 3:40 p.m.")).toBeInTheDocument();
111+
expect(getByText("Jul 4, 11:40 a.m. (EDT)")).toBeInTheDocument();
112+
expect(getByText("Jul 4, 8:40 a.m. (PDT)")).toBeInTheDocument();
113+
expect(getByText(fiveMinutesAgo.getTime() / 1000)).toBeInTheDocument();
114+
});
115+
});

Diff for: src/components/DateDetails/DateDetails.tsx

+199
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,199 @@
1+
import dayjs, { Dayjs } from "dayjs";
2+
import advancedFormat from "dayjs/plugin/advancedFormat";
3+
import duration from "dayjs/plugin/duration";
4+
import localizedFormat from "dayjs/plugin/localizedFormat";
5+
import relativeTime from "dayjs/plugin/relativeTime";
6+
import timezone from "dayjs/plugin/timezone";
7+
import updateLocale from "dayjs/plugin/updateLocale";
8+
import utc from "dayjs/plugin/utc";
9+
import { styled } from "styled-components";
10+
11+
import { Popover } from "@/components/Popover/Popover";
12+
import { Text } from "@/components/Typography/Text/Text";
13+
import { linkStyles, StyledLinkProps } from "@/components/Link/common";
14+
import { GridContainer } from "@/components/GridContainer/GridContainer";
15+
import { Container } from "@/components/Container/Container";
16+
17+
dayjs.extend(advancedFormat);
18+
dayjs.extend(duration);
19+
dayjs.extend(localizedFormat);
20+
dayjs.extend(updateLocale);
21+
dayjs.extend(utc);
22+
23+
const thresholds = [
24+
{ l: "s", r: 1, d: "second" },
25+
{ l: "ss", r: 56, d: "second" },
26+
{ l: "m", r: 90, d: "second" },
27+
{ l: "mm", r: 55, d: "minute" },
28+
{ l: "h", r: 90, d: "minute" },
29+
{ l: "hh", r: 22, d: "hour" },
30+
{ l: "d", r: 40, d: "hour" },
31+
{ l: "dd", r: 31, d: "day" },
32+
{ l: "M", r: 45, d: "day" },
33+
{ l: "MM", r: 11, d: "month" },
34+
{ l: "y", r: 17, d: "month" },
35+
{ l: "yy", r: 2, d: "year" },
36+
];
37+
38+
dayjs.extend(relativeTime, { thresholds });
39+
40+
dayjs.updateLocale("en", {
41+
relativeTime: {
42+
future: "In %s",
43+
past: "%s ago",
44+
s: "a few seconds",
45+
ss: "%d seconds",
46+
m: "1 minute",
47+
mm: "%d minutes",
48+
h: "1 hour",
49+
hh: "%d hours",
50+
d: "1 day",
51+
dd: "%d days",
52+
w: "1 week",
53+
ww: "%d weeks",
54+
M: "1 month",
55+
MM: "%d months",
56+
y: "1 year",
57+
yy: "%d years",
58+
},
59+
});
60+
61+
const UnderlinedTrigger = styled(Popover.Trigger)<StyledLinkProps>`
62+
${linkStyles}
63+
`;
64+
65+
const formatDateDetails = (date: Dayjs, timezone?: string): string => {
66+
const isCurrentYear = dayjs().year() === date.year();
67+
const formatForCurrentYear = "MMM D, h:mm a";
68+
const formatForPastYear = "MMM D, YYYY, h:mm a";
69+
70+
if (isCurrentYear) {
71+
if (timezone) {
72+
const dateWithTimezone = date.tz(timezone);
73+
return dateWithTimezone
74+
.format(formatForCurrentYear)
75+
.replace("am", "a.m.")
76+
.replace("pm", "p.m.");
77+
}
78+
79+
return date.format(formatForCurrentYear).replace("am", "a.m.").replace("pm", "p.m.");
80+
}
81+
82+
if (timezone) {
83+
const dateWithTimezone = date.tz(timezone);
84+
return dateWithTimezone
85+
.format(formatForPastYear)
86+
.replace("am", "a.m.")
87+
.replace("pm", "p.m.");
88+
}
89+
return date.format(formatForPastYear).replace("am", "a.m.").replace("pm", "p.m.");
90+
};
91+
92+
const formatTimezone = (date: Dayjs, timezone: string): string => {
93+
return (
94+
new Intl.DateTimeFormat(undefined, {
95+
timeZone: timezone,
96+
timeZoneName: "short",
97+
})
98+
.formatToParts(date.toDate())
99+
.find(part => part.type === "timeZoneName")?.value ?? date.format("z")
100+
);
101+
};
102+
103+
export type ArrowPosition = "top" | "right" | "left" | "bottom";
104+
105+
export interface DateDetailsProps {
106+
date: Date;
107+
side?: ArrowPosition;
108+
systemTimeZone?: string;
109+
}
110+
111+
export const DateDetails = ({
112+
date,
113+
side = "top",
114+
systemTimeZone = "America/New_York",
115+
}: DateDetailsProps) => {
116+
const dayjsDate = dayjs(date);
117+
118+
let systemTime;
119+
if (systemTimeZone) {
120+
dayjs.extend(timezone);
121+
try {
122+
systemTime = dayjsDate.tz(systemTimeZone);
123+
} catch {
124+
systemTime = dayjsDate.tz("America/New_York");
125+
}
126+
}
127+
128+
return (
129+
<Popover>
130+
<UnderlinedTrigger
131+
$size="sm"
132+
$weight="medium"
133+
>
134+
<Text size="sm">{dayjs.utc(date).fromNow()}</Text>
135+
</UnderlinedTrigger>
136+
<Popover.Content
137+
side={side}
138+
showArrow
139+
>
140+
<GridContainer
141+
columnGap="xl"
142+
gridTemplateColumns="repeat(2, auto)"
143+
gap="sm"
144+
>
145+
<Text
146+
color="muted"
147+
size="sm"
148+
>
149+
Local
150+
</Text>
151+
<Container justifyContent="end">
152+
<Text size="sm">
153+
{formatDateDetails(dayjsDate)} (
154+
{formatTimezone(dayjsDate, dayjs.tz.guess())})
155+
</Text>
156+
</Container>
157+
158+
{systemTime && (
159+
<>
160+
<Text
161+
color="muted"
162+
size="sm"
163+
>
164+
System
165+
</Text>
166+
167+
<Container justifyContent="end">
168+
<Text size="sm">
169+
{formatDateDetails(systemTime, systemTimeZone)} (
170+
{formatTimezone(systemTime, systemTimeZone)})
171+
</Text>
172+
</Container>
173+
</>
174+
)}
175+
176+
<Text
177+
color="muted"
178+
size="sm"
179+
>
180+
UTC
181+
</Text>
182+
<Container justifyContent="end">
183+
<Text size="sm">{formatDateDetails(dayjsDate.utc(), "UTC")}</Text>
184+
</Container>
185+
186+
<Text
187+
color="muted"
188+
size="sm"
189+
>
190+
Unix
191+
</Text>
192+
<Container justifyContent="end">
193+
<Text size="sm">{Math.round(date.getTime() / 1000)}</Text>
194+
</Container>
195+
</GridContainer>
196+
</Popover.Content>
197+
</Popover>
198+
);
199+
};

0 commit comments

Comments
 (0)