Skip to content

Commit

Permalink
Create DateDetails component (#460)
Browse files Browse the repository at this point in the history
  • Loading branch information
hoorayimhelping authored Feb 12, 2025
1 parent 302e179 commit 1eda1dd
Show file tree
Hide file tree
Showing 8 changed files with 387 additions and 7 deletions.
7 changes: 7 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 54 additions & 0 deletions src/components/DateDetails/DateDetails.stories.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<DateDetails
date={date}
side={args.side}
systemTimeZone={args.systemTimeZone}
/>
);
},
};
115 changes: 115 additions & 0 deletions src/components/DateDetails/DateDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<DateDetails
date={fiveMinutesAgo}
systemTimeZone={systemTimeZone}
/>
);

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(
<DateDetails
date={oneYearAgo}
systemTimeZone={systemTimeZone}
/>
);

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(
<DateDetails
date={fiveMinutesAgo}
systemTimeZone={systemTimeZone}
/>
);

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();
});
});
199 changes: 199 additions & 0 deletions src/components/DateDetails/DateDetails.tsx
Original file line number Diff line number Diff line change
@@ -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)<StyledLinkProps>`
${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 (
<Popover>
<UnderlinedTrigger
$size="sm"
$weight="medium"
>
<Text size="sm">{dayjs.utc(date).fromNow()}</Text>
</UnderlinedTrigger>
<Popover.Content
side={side}
showArrow
>
<GridContainer
columnGap="xl"
gridTemplateColumns="repeat(2, auto)"
gap="sm"
>
<Text
color="muted"
size="sm"
>
Local
</Text>
<Container justifyContent="end">
<Text size="sm">
{formatDateDetails(dayjsDate)} (
{formatTimezone(dayjsDate, dayjs.tz.guess())})
</Text>
</Container>

{systemTime && (
<>
<Text
color="muted"
size="sm"
>
System
</Text>

<Container justifyContent="end">
<Text size="sm">
{formatDateDetails(systemTime, systemTimeZone)} (
{formatTimezone(systemTime, systemTimeZone)})
</Text>
</Container>
</>
)}

<Text
color="muted"
size="sm"
>
UTC
</Text>
<Container justifyContent="end">
<Text size="sm">{formatDateDetails(dayjsDate.utc(), "UTC")}</Text>
</Container>

<Text
color="muted"
size="sm"
>
Unix
</Text>
<Container justifyContent="end">
<Text size="sm">{Math.round(date.getTime() / 1000)}</Text>
</Container>
</GridContainer>
</Popover.Content>
</Popover>
);
};
Loading

0 comments on commit 1eda1dd

Please sign in to comment.