Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Create DateDetails component #460

Merged
merged 15 commits into from
Feb 12, 2025
Merged
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