chore: replace date-fns by dayjs (#18022)

This change replaces date-fns with dayjs throughout the codebase for
more consistent date/time handling and to reduce bundle size. It also
tries to make the formatting and usage consistent.

**Why dayjs over date-fns?**
Just because we were using dayjs more broadly. Its formatting
capabilities, were also easier to extend.
This commit is contained in:
Bruno Quaresma
2025-05-25 00:32:36 -03:00
committed by GitHub
parent a605c09d2b
commit 5cdda2ea7d
20 changed files with 244 additions and 181 deletions
+13 -18
View File
@@ -2,7 +2,13 @@ import type { Page } from "@playwright/test";
import { expect } from "@playwright/test";
import { API, type DeploymentConfig } from "api/api";
import type { SerpentOption } from "api/typesGenerated";
import { formatDuration, intervalToDuration } from "date-fns";
import dayjs from "dayjs";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(duration);
dayjs.extend(relativeTime);
import { humanDuration } from "utils/time";
import { coderPort, defaultPassword } from "./constants";
import { type LoginOptions, findSessionToken, randomName } from "./helpers";
@@ -237,13 +243,6 @@ export async function verifyConfigFlagString(
await expect(configOption).toHaveText(opt.value as any);
}
export async function verifyConfigFlagEmpty(page: Page, flag: string) {
const configOption = page.locator(
`div.options-table .option-${flag} .option-value-empty`,
);
await expect(configOption).toHaveText("Not set");
}
export async function verifyConfigFlagArray(
page: Page,
config: DeploymentConfig,
@@ -290,19 +289,15 @@ export async function verifyConfigFlagDuration(
flag: string,
) {
const opt = findConfigOption(config, flag);
if (typeof opt.value !== "number") {
throw new Error(
`Option with env ${flag} should be a number, but got ${typeof opt.value}.`,
);
}
const configOption = page.locator(
`div.options-table .option-${flag} .option-value-string`,
);
//
await expect(configOption).toHaveText(
formatDuration(
// intervalToDuration takes ms, so convert nanoseconds to ms
intervalToDuration({
start: 0,
end: (opt.value as number) / 1e6,
}),
),
);
await expect(configOption).toHaveText(humanDuration(opt.value / 1e6));
}
export function findConfigOption(
@@ -5,7 +5,6 @@ import {
verifyConfigFlagArray,
verifyConfigFlagBoolean,
verifyConfigFlagDuration,
verifyConfigFlagEmpty,
verifyConfigFlagString,
} from "../../api";
import { login } from "../../helpers";
@@ -28,7 +27,11 @@ test("enabled observability settings", async ({ page }) => {
await verifyConfigFlagBoolean(page, config, "enable-terraform-debug-mode");
await verifyConfigFlagBoolean(page, config, "enable-terraform-debug-mode");
await verifyConfigFlagDuration(page, config, "health-check-refresh");
await verifyConfigFlagEmpty(page, "health-check-threshold-database");
await verifyConfigFlagDuration(
page,
config,
"health-check-threshold-database",
);
await verifyConfigFlagString(page, config, "log-human");
await verifyConfigFlagString(page, config, "prometheus-address");
await verifyConfigFlagArray(
+2 -1
View File
@@ -85,12 +85,12 @@
"color-convert": "2.0.1",
"cron-parser": "4.9.0",
"cronstrue": "2.50.0",
"date-fns": "2.30.0",
"dayjs": "1.11.13",
"emoji-mart": "5.6.0",
"file-saver": "2.0.5",
"formik": "2.4.6",
"front-matter": "4.0.2",
"humanize-duration": "3.32.2",
"jszip": "3.10.1",
"lodash": "4.17.21",
"lucide-react": "0.474.0",
@@ -149,6 +149,7 @@
"@types/color-convert": "2.0.4",
"@types/express": "4.17.17",
"@types/file-saver": "2.0.7",
"@types/humanize-duration": "3.27.4",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.15",
"@types/node": "20.17.16",
+16 -3
View File
@@ -166,9 +166,6 @@ importers:
cronstrue:
specifier: 2.50.0
version: 2.50.0
date-fns:
specifier: 2.30.0
version: 2.30.0
dayjs:
specifier: 1.11.13
version: 1.11.13
@@ -184,6 +181,9 @@ importers:
front-matter:
specifier: 4.0.2
version: 4.0.2
humanize-duration:
specifier: 3.32.2
version: 3.32.2
jszip:
specifier: 3.10.1
version: 3.10.1
@@ -353,6 +353,9 @@ importers:
'@types/file-saver':
specifier: 2.0.7
version: 2.0.7
'@types/humanize-duration':
specifier: 3.27.4
version: 3.27.4
'@types/jest':
specifier: 29.5.14
version: 29.5.14
@@ -2574,6 +2577,9 @@ packages:
'@types/http-errors@2.0.1':
resolution: {integrity: sha512-/K3ds8TRAfBvi5vfjuz8y6+GiAYBZ0x4tXv1Av6CWBWn0IlADc+ZX9pMq7oU0fNQPnBwIZl3rmeLp6SBApbxSQ==, tarball: https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.1.tgz}
'@types/humanize-duration@3.27.4':
resolution: {integrity: sha512-yaf7kan2Sq0goxpbcwTQ+8E9RP6HutFBPv74T/IA/ojcHKhuKVlk2YFYyHhWZeLvZPzzLE3aatuQB4h0iqyyUA==, tarball: https://registry.npmjs.org/@types/humanize-duration/-/humanize-duration-3.27.4.tgz}
'@types/istanbul-lib-coverage@2.0.5':
resolution: {integrity: sha512-zONci81DZYCZjiLe0r6equvZut0b+dBRPBN5kBDjsONnutYNtJMoWQ9uR2RkL1gLG9NMTzvf+29e5RFfPbeKhQ==, tarball: https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.5.tgz}
@@ -3992,6 +3998,9 @@ packages:
resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==, tarball: https://registry.npmjs.org/human-signals/-/human-signals-2.1.0.tgz}
engines: {node: '>=10.17.0'}
humanize-duration@3.32.2:
resolution: {integrity: sha512-jcTwWYeCJf4dN5GJnjBmHd42bNyK94lY49QTkrsAQrMTUoIYLevvDpmQtg5uv8ZrdIRIbzdasmSNZ278HHUPEg==, tarball: https://registry.npmjs.org/humanize-duration/-/humanize-duration-3.32.2.tgz}
iconv-lite@0.4.24:
resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==, tarball: https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz}
engines: {node: '>=0.10.0'}
@@ -8731,6 +8740,8 @@ snapshots:
'@types/http-errors@2.0.1': {}
'@types/humanize-duration@3.27.4': {}
'@types/istanbul-lib-coverage@2.0.5': {}
'@types/istanbul-lib-coverage@2.0.6': {}
@@ -10330,6 +10341,8 @@ snapshots:
human-signals@2.1.0: {}
humanize-duration@3.32.2: {}
iconv-lite@0.4.24:
dependencies:
safer-buffer: 2.1.2
+11 -9
View File
@@ -1,10 +1,8 @@
import { useTheme } from "@emotion/react";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import type { FC, HTMLAttributes } from "react";
import { cn } from "utils/cn";
dayjs.extend(relativeTime);
import { isAfter, relativeTime, subtractTime } from "utils/time";
interface LastSeenProps
extends Omit<HTMLAttributes<HTMLSpanElement>, "children"> {
@@ -15,21 +13,25 @@ interface LastSeenProps
export const LastSeen: FC<LastSeenProps> = ({ at, className, ...attrs }) => {
const theme = useTheme();
const t = dayjs(at);
const now = dayjs();
const now = new Date();
const oneHourAgo = subtractTime(now, 1, "hour");
const threeDaysAgo = subtractTime(now, 3, "day");
const oneMonthAgo = subtractTime(now, 1, "month");
const centuryAgo = subtractTime(now, 100, "year");
let message = t.fromNow();
let message = relativeTime(at);
let color = theme.palette.text.secondary;
if (t.isAfter(now.subtract(1, "hour"))) {
if (isAfter(at, oneHourAgo)) {
// Since the agent reports on a 10m interval,
// the last_used_at can be inaccurate when recent.
message = "Now";
color = theme.roles.success.fill.solid;
} else if (t.isAfter(now.subtract(3, "day"))) {
} else if (isAfter(at, threeDaysAgo)) {
color = theme.experimental.l2.text;
} else if (t.isAfter(now.subtract(1, "month"))) {
} else if (isAfter(at, oneMonthAgo)) {
color = theme.roles.warning.fill.solid;
} else if (t.isAfter(now.subtract(100, "year"))) {
} else if (isAfter(at, centuryAgo)) {
color = theme.roles.error.fill.solid;
} else {
message = "Never";
+11 -4
View File
@@ -1,13 +1,20 @@
import formatRelative from "date-fns/formatRelative";
import subDays from "date-fns/subDays";
import dayjs from "dayjs";
import calendar from "dayjs/plugin/calendar";
dayjs.extend(calendar);
export const createDisplayDate = (
date: Date,
base: Date = new Date(),
): string => {
const lastWeek = subDays(base, 7);
const lastWeek = dayjs(base).subtract(7, "day").toDate();
if (date >= lastWeek) {
return formatRelative(date, base).split(" at ")[0];
return dayjs(date).calendar(dayjs(base), {
sameDay: "[Today]",
lastDay: "[Yesterday]",
lastWeek: "[last] dddd",
sameElse: "MM/DD/YYYY",
});
}
return date.toLocaleDateString();
};
@@ -1,8 +1,12 @@
import Tooltip from "@mui/material/Tooltip";
import type { Workspace } from "api/typesGenerated";
import { Badge } from "components/Badge/Badge";
import { formatDistanceToNow } from "date-fns";
import type { FC } from "react";
import {
DATE_FORMAT,
formatDateTime,
relativeTimeWithoutSuffix,
} from "utils/time";
export type WorkspaceDormantBadgeProps = {
workspace: Workspace;
@@ -11,25 +15,14 @@ export type WorkspaceDormantBadgeProps = {
export const WorkspaceDormantBadge: FC<WorkspaceDormantBadgeProps> = ({
workspace,
}) => {
const formatDate = (dateStr: string): string => {
const date = new Date(dateStr);
return date.toLocaleDateString(undefined, {
month: "long",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "numeric",
});
};
return workspace.deleting_at ? (
<Tooltip
title={
<>
This workspace has not been used for{" "}
{formatDistanceToNow(Date.parse(workspace.last_used_at))} and has been
{relativeTimeWithoutSuffix(workspace.last_used_at)} and has been
marked dormant. It is scheduled to be deleted on{" "}
{formatDate(workspace.deleting_at)}.
{formatDateTime(workspace.deleting_at, DATE_FORMAT.FULL_DATETIME)}.
</>
}
>
@@ -42,7 +35,7 @@ export const WorkspaceDormantBadge: FC<WorkspaceDormantBadgeProps> = ({
title={
<>
This workspace has not been used for{" "}
{formatDistanceToNow(Date.parse(workspace.last_used_at))} and has been
{relativeTimeWithoutSuffix(workspace.last_used_at)} and has been
marked dormant. It is not scheduled for auto-deletion but will become
a candidate if auto-deletion is enabled on this template.
</>
@@ -5,7 +5,6 @@ import type { GetLicensesResponse } from "api/api";
import { ConfirmDialog } from "components/Dialogs/ConfirmDialog/ConfirmDialog";
import { Pill } from "components/Pill/Pill";
import { Stack } from "components/Stack/Stack";
import { compareAsc } from "date-fns";
import dayjs from "dayjs";
import { type FC, useState } from "react";
@@ -92,10 +91,7 @@ export const LicenseCard: FC<LicenseCardProps> = ({
alignItems="center"
width="134px" // standardize width of date column
>
{compareAsc(
new Date(license.claims.license_expires * 1000),
new Date(),
) < 1 ? (
{dayjs(license.claims.license_expires * 1000).isBefore(dayjs()) ? (
<Pill css={styles.expiredBadge} type="error">
Expired
</Pill>
@@ -1,5 +1,5 @@
import type { SerpentOption } from "api/typesGenerated";
import { formatDuration, intervalToDuration } from "date-fns";
import { humanDuration } from "utils/time";
// optionValue is a helper function to format the value of a specific deployment options
export function optionValue(
@@ -14,13 +14,7 @@ export function optionValue(
}
switch (k) {
case "format_duration":
return formatDuration(
// intervalToDuration takes ms, so convert nanoseconds to ms
intervalToDuration({
start: 0,
end: (option.value as number) / 1e6,
}),
);
return humanDuration((option.value as number) / 1e6);
// Add additional cases here as needed.
}
}
@@ -7,15 +7,7 @@ import {
PopoverContent,
PopoverTrigger,
} from "components/deprecated/Popover/Popover";
import {
addDays,
addHours,
format,
isToday,
startOfDay,
startOfHour,
subDays,
} from "date-fns";
import dayjs from "dayjs";
import { MoveRightIcon } from "lucide-react";
import { type ComponentProps, type FC, useRef, useState } from "react";
import { DateRangePicker, createStaticRanges } from "react-date-range";
@@ -55,9 +47,9 @@ export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger>
<Button variant="outline">
<span>{format(value.startDate, "MMM d, Y")}</span>
<span>{dayjs(value.startDate).format("MMM D, YYYY")}</span>
<MoveRightIcon />
<span>{format(value.endDate, "MMM d, Y")}</span>
<span>{dayjs(value.endDate).format("MMM D, YYYY")}</span>
</Button>
</PopoverTrigger>
<PopoverContent>
@@ -79,10 +71,10 @@ export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
const endDate = range.endDate as Date;
const now = new Date();
onChange({
startDate: startOfDay(startDate),
endDate: isToday(endDate)
? startOfHour(addHours(now, 1))
: startOfDay(addDays(endDate, 1)),
startDate: dayjs(startDate).startOf("day").toDate(),
endDate: dayjs(endDate).isSame(dayjs(), "day")
? dayjs(now).startOf("hour").add(1, "hour").toDate()
: dayjs(endDate).startOf("day").add(1, "day").toDate(),
});
setOpen(false);
}}
@@ -102,28 +94,28 @@ export const DateRange: FC<DateRangeProps> = ({ value, onChange }) => {
{
label: "Yesterday",
range: () => ({
startDate: subDays(new Date(), 1),
endDate: subDays(new Date(), 1),
startDate: dayjs().subtract(1, "day").toDate(),
endDate: dayjs().subtract(1, "day").toDate(),
}),
},
{
label: "Last 7 days",
range: () => ({
startDate: subDays(new Date(), 6),
startDate: dayjs().subtract(6, "day").toDate(),
endDate: new Date(),
}),
},
{
label: "Last 14 days",
range: () => ({
startDate: subDays(new Date(), 13),
startDate: dayjs().subtract(13, "day").toDate(),
endDate: new Date(),
}),
},
{
label: "Last 30 days",
range: () => ({
startDate: subDays(new Date(), 29),
startDate: dayjs().subtract(29, "day").toDate(),
endDate: new Date(),
}),
},
@@ -35,14 +35,6 @@ import {
} from "components/HelpTooltip/HelpTooltip";
import { Loader } from "components/Loader/Loader";
import { Stack } from "components/Stack/Stack";
import {
addHours,
addWeeks,
format,
startOfDay,
startOfHour,
subDays,
} from "date-fns";
import { useEmbeddedMetadata } from "hooks/useEmbeddedMetadata";
import { CircleCheck as CircleCheckIcon } from "lucide-react";
import { useTemplateLayoutContext } from "pages/TemplatePage/TemplateLayout";
@@ -57,6 +49,13 @@ import { Helmet } from "react-helmet-async";
import { useQuery } from "react-query";
import { useSearchParams } from "react-router-dom";
import { getLatencyColor } from "utils/latency";
import {
addTime,
formatDateTime,
startOfDay,
startOfHour,
subtractTime,
} from "utils/time";
import { getTemplatePageTitle } from "../utils";
import { DateRange as DailyPicker, type DateRangeValue } from "./DateRange";
import { type InsightsInterval, IntervalMenu } from "./IntervalMenu";
@@ -138,7 +137,7 @@ export default function TemplateInsightsPage() {
const getDefaultInterval = (template: Template) => {
const now = new Date();
const templateCreateDate = new Date(template.created_at);
const hasFiveWeeksOrMore = addWeeks(templateCreateDate, 5) < now;
const hasFiveWeeksOrMore = addTime(templateCreateDate, 5, "week") < now;
return hasFiveWeeksOrMore ? "week" : "day";
};
@@ -162,9 +161,9 @@ const getDateRange = (
// instantiation.
const today = new Date();
return {
startDate: startOfDay(subDays(today, 6)),
startDate: startOfDay(subtractTime(today, 6, "day")),
// Add one hour to endDate to include real-time data for today.
endDate: addHours(startOfHour(today), 1),
endDate: addTime(startOfHour(today), 1, "hour"),
};
}
@@ -910,7 +909,7 @@ function formatTime(seconds: number): string {
}
function toISOLocal(d: Date, offset: number) {
return format(d, `yyyy-MM-dd'T'HH:mm:ss${formatOffset(offset)}`);
return formatDateTime(d, `YYYY-MM-DD[T]HH:mm:ss${formatOffset(offset)}`);
}
function formatOffset(offset: number): string {
@@ -1,7 +1,7 @@
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import { differenceInWeeks } from "date-fns";
import dayjs from "dayjs";
import { ChevronDownIcon } from "lucide-react";
import { CheckIcon } from "lucide-react";
import { type FC, useRef, useState } from "react";
@@ -20,7 +20,10 @@ interface WeekPickerProps {
export const WeekPicker: FC<WeekPickerProps> = ({ value, onChange }) => {
const anchorRef = useRef<HTMLButtonElement>(null);
const [open, setOpen] = useState(false);
const numberOfWeeks = differenceInWeeks(value.endDate, value.startDate);
const numberOfWeeks = dayjs(value.endDate).diff(
dayjs(value.startDate),
"week",
);
const handleClose = () => {
setOpen(false);
@@ -1,8 +1,8 @@
import { startOfDay, subDays } from "date-fns";
import { startOfDay, subtractTime } from "utils/time";
export const lastWeeks = (numberOfWeeks: number) => {
const now = new Date();
const endDate = startOfDay(subDays(now, 1));
const startDate = startOfDay(subDays(endDate, 7 * numberOfWeeks));
const endDate = subtractTime(startOfDay(now), 1, "day");
const startDate = subtractTime(endDate, 7 * numberOfWeeks, "day");
return { startDate, endDate };
};
@@ -1,6 +1,6 @@
import { workspaces } from "api/queries/workspaces";
import type { Template, Workspace } from "api/typesGenerated";
import { compareAsc } from "date-fns";
import dayjs from "dayjs";
import { useQuery } from "react-query";
import type { TemplateScheduleFormValues } from "./formHelpers";
@@ -29,7 +29,10 @@ export const useWorkspacesToGoDormant = (
formValues.time_til_dormant_ms,
);
if (compareAsc(proposedLocking, fromDate) < 1) {
if (
dayjs(proposedLocking).isBefore(dayjs(fromDate)) ||
dayjs(proposedLocking).isSame(dayjs(fromDate))
) {
return workspace;
}
});
@@ -56,7 +59,10 @@ export const useWorkspacesToBeDeleted = (
formValues.time_til_dormant_autodelete_ms,
);
if (compareAsc(proposedLocking, fromDate) < 1) {
if (
dayjs(proposedLocking).isBefore(dayjs(fromDate)) ||
dayjs(proposedLocking).isSame(dayjs(fromDate))
) {
return workspace;
}
});
+4 -11
View File
@@ -13,7 +13,8 @@ import {
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import { formatDistance } from "date-fns";
import { timeFrom } from "utils/time";
import {
ChevronDownIcon,
ChevronUpIcon,
@@ -153,9 +154,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
{latestStatus.message}
</span>
<span className="text-xs text-content-secondary first-letter:uppercase block pl-[26px]">
{formatDistance(new Date(latestStatus.created_at), comparisonDate, {
addSuffix: true,
})}
{timeFrom(new Date(latestStatus.created_at), comparisonDate)}
</span>
</div>
@@ -216,13 +215,7 @@ export const AppStatuses: FC<AppStatusesProps> = ({
{displayStatuses &&
otherStatuses.map((status) => {
const statusTime = new Date(status.created_at);
const formattedTimestamp = formatDistance(
statusTime,
comparisonDate,
{
addSuffix: true,
},
);
const formattedTimestamp = timeFrom(statusTime, comparisonDate);
return (
<div
@@ -7,13 +7,15 @@ import type {
WorkspaceBuild,
} from "api/typesGenerated";
import { MemoizedInlineMarkdown } from "components/Markdown/Markdown";
import formatDistanceToNow from "date-fns/formatDistanceToNow";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { TriangleAlertIcon } from "lucide-react";
import { InfoIcon } from "lucide-react";
import { useDashboard } from "modules/dashboard/useDashboard";
import { TemplateUpdateMessage } from "modules/templates/TemplateUpdateMessage";
import { type FC, useEffect, useState } from "react";
dayjs.extend(relativeTime);
import { useQuery } from "react-query";
import type { WorkspacePermissions } from "../../../modules/workspaces/permissions";
import {
@@ -147,19 +149,18 @@ export const WorkspaceNotifications: FC<WorkspaceNotificationsProps> = ({
detail: workspace.deleting_at ? (
<>
This workspace has not been used for{" "}
{formatDistanceToNow(Date.parse(workspace.last_used_at))} and was
marked dormant on {formatDate(workspace.dormant_at, false)}. It is
scheduled to be deleted on {formatDate(workspace.deleting_at, true)}.
To keep it you must activate the workspace.
{dayjs(workspace.last_used_at).fromNow(true)} and was marked dormant
on {formatDate(workspace.dormant_at, false)}. It is scheduled to be
deleted on {formatDate(workspace.deleting_at, true)}. To keep it you
must activate the workspace.
</>
) : (
<>
This workspace has not been used for{" "}
{formatDistanceToNow(Date.parse(workspace.last_used_at))} and was
marked dormant on {formatDate(workspace.dormant_at, false)}. It is not
scheduled for auto-deletion but will become a candidate if
auto-deletion is enabled on this template. To keep it you must
activate the workspace.
{dayjs(workspace.last_used_at).fromNow(true)} and was marked dormant
on {formatDate(workspace.dormant_at, false)}. It is not scheduled for
auto-deletion but will become a candidate if auto-deletion is enabled
on this template. To keep it you must activate the workspace.
</>
),
});
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react";
import { expect, screen, userEvent, waitFor, within } from "@storybook/test";
import { getWorkspaceQuotaQueryKey } from "api/queries/workspaceQuota";
import type { Workspace, WorkspaceQuota } from "api/typesGenerated";
import { addHours, addMinutes } from "date-fns";
import dayjs from "dayjs";
import {
MockOrganization,
MockTemplate,
@@ -72,7 +72,7 @@ export const ReadyWithDeadline: Story = {
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addHours(new Date(), 8).toISOString();
return dayjs().add(8, "hour").toISOString();
},
},
},
@@ -89,7 +89,7 @@ export const Connected: Story = {
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addHours(new Date(), 8).toISOString();
return dayjs().add(8, "hour").toISOString();
},
},
},
@@ -123,10 +123,10 @@ export const ConnectedWithMaxDeadline: Story = {
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addHours(new Date(), 1).toISOString();
return dayjs().add(1, "hour").toISOString();
},
get max_deadline() {
return addHours(new Date(), 8).toISOString();
return dayjs().add(8, "hour").toISOString();
},
},
},
@@ -160,10 +160,10 @@ export const ConnectedWithMaxDeadlineSoon: Story = {
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addHours(new Date(), 1).toISOString();
return dayjs().add(1, "hour").toISOString();
},
get max_deadline() {
return addHours(new Date(), 1).toISOString();
return dayjs().add(1, "hour").toISOString();
},
},
},
@@ -202,7 +202,7 @@ export const WithApproachingDeadline: Story = {
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addMinutes(new Date(), 30).toISOString();
return dayjs().add(30, "minute").toISOString();
},
},
},
@@ -228,7 +228,7 @@ export const WithFarAwayDeadline: Story = {
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addHours(new Date(), 8).toISOString();
return dayjs().add(8, "hour").toISOString();
},
},
},
@@ -254,7 +254,7 @@ export const WithFarAwayDeadlineRequiredByTemplate: Story = {
latest_build: {
...MockWorkspace.latest_build,
get deadline() {
return addHours(new Date(), 8).toISOString();
return dayjs().add(8, "hour").toISOString();
},
},
},
@@ -236,9 +236,9 @@ describe("ttlShutdownAt", () => {
"Your workspace will shut down 12 minutes after its next start.",
],
[
"48.258 hours --> helper text shows shutdown after 2 days and 15 minutes and 28 seconds",
"48.258 hours --> helper text shows shutdown after 2 days, 15 minutes and 29 seconds",
48.258,
"Your workspace will shut down 2 days and 15 minutes and 28 seconds after its next start.",
"Your workspace will shut down 2 days, 15 minutes and 29 seconds after its next start.",
],
])("%p", (_, ttlHours, expected) => {
expect(ttlShutdownAt(ttlHours)).toEqual(expected);
@@ -21,13 +21,8 @@ import {
StackLabel,
StackLabelHelperText,
} from "components/StackLabel/StackLabel";
import { formatDuration, intervalToDuration } from "date-fns";
import dayjs from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import duration from "dayjs/plugin/duration";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { type FormikTouched, useFormik } from "formik";
import {
defaultSchedule,
@@ -35,15 +30,11 @@ import {
} from "pages/WorkspaceSettingsPage/WorkspaceSchedulePage/schedule";
import type { ChangeEvent, FC } from "react";
import { getFormHelpers } from "utils/formUtils";
import { humanDuration } from "utils/time";
import { timeZones } from "utils/timeZones";
import * as Yup from "yup";
// REMARK: some plugins depend on utc, so it's listed first. Otherwise they're
// sorted alphabetically.
dayjs.extend(utc);
dayjs.extend(advancedFormat);
dayjs.extend(duration);
dayjs.extend(relativeTime);
// Need dayjs.tz functions for timezone validation
dayjs.extend(timezone);
export const Language = {
@@ -163,6 +154,7 @@ export const validationSchema = Yup.object({
// Unfortunately, there's not a good API on dayjs at this time for
// evaluating a timezone. Attempt to parse today in the supplied timezone
// and return as valid if the function doesn't throw.
// Need to use dayjs.tz directly here as our utility functions don't expose validation
try {
dayjs.tz(dayjs(), value);
return true;
@@ -394,10 +386,8 @@ export const WorkspaceScheduleForm: FC<WorkspaceScheduleFormProps> = ({
<>
Set how many hours should elapse after the workspace started before
the workspace automatically shuts down. This will be extended by{" "}
{dayjs
.duration({ milliseconds: template.activity_bump_ms })
.humanize()}{" "}
after last activity in the workspace was detected.
{humanDuration(template.activity_bump_ms)} after last activity in
the workspace was detected.
</>
}
>
@@ -471,10 +461,7 @@ export const ttlShutdownAt = (formTTL: number): string => {
}
try {
return `Your workspace will shut down ${formatDuration(
intervalToDuration({ start: 0, end: formTTL * 60 * 60 * 1000 }),
{ delimiter: " and " },
)} after its next start.`;
return `Your workspace will shut down ${humanDuration(formTTL * 60 * 60 * 1000)} after its next start.`;
} catch (e) {
if (e instanceof RangeError) {
return Language.errorTtlMax;
+102 -24
View File
@@ -1,24 +1,70 @@
import dayjs from "dayjs";
import dayjs, { type Dayjs } from "dayjs";
import duration from "dayjs/plugin/duration";
import DayJSRelativeTime from "dayjs/plugin/relativeTime";
import relativeTimePlugin from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import humanizeDuration from "humanize-duration";
// Load required plugins
dayjs.extend(duration);
dayjs.extend(DayJSRelativeTime);
dayjs.extend(relativeTimePlugin);
dayjs.extend(utc);
dayjs.extend(timezone);
// Time conversion constants
const TIME_CONSTANTS = {
MS_PER_SECOND: 1000,
MS_PER_MINUTE: 60 * 1000,
MS_PER_HOUR: 60 * 60 * 1000,
MS_PER_DAY: 24 * 60 * 60 * 1000,
NS_PER_MS: 1e6,
SECONDS_PER_MINUTE: 60,
MINUTES_PER_HOUR: 60,
HOURS_PER_DAY: 24,
};
export type TimeUnit = "days" | "hours";
export type DateTimeInput = Date | string | number | Dayjs | null | undefined;
// Standard format strings
// https://day.js.org/docs/en/display/format
export const DATE_FORMAT = {
ISO_DATE: "YYYY-MM-DD",
ISO_DATETIME: "YYYY-MM-DD HH:mm:ss",
FULL_DATE: "MMMM D, YYYY",
MEDIUM_DATE: "MMM D, YYYY",
FULL_DATETIME: "MMMM D, YYYY h:mm A",
SHORT_DATE: "MM/DD/YYYY",
TIME_24H: "HH:mm:ss",
TIME_12H: "h:mm A",
UTC_OFFSET: "Z",
} as const;
// Format functions
export function formatDateTime(
date: DateTimeInput,
format: string = DATE_FORMAT.ISO_DATETIME,
) {
return dayjs(date).format(format);
}
// Duration functions
export function humanDuration(durationInMs: number) {
if (durationInMs === 0) {
return "0 hours";
}
return humanizeDuration(durationInMs, {
conjunction: " and ",
serialComma: false,
round: true,
units: ["y", "mo", "w", "d", "h", "m", "s", "ms"],
largest: 3,
});
}
const timeUnit = suggestedTimeUnit(durationInMs);
const durationValue =
timeUnit === "days"
? durationInDays(durationInMs)
: durationInHours(durationInMs);
export function durationInHours(durationMs: number): number {
return durationMs / TIME_CONSTANTS.MS_PER_HOUR;
}
return `${durationValue} ${timeUnit}`;
export function durationInDays(durationMs: number): number {
return durationMs / TIME_CONSTANTS.MS_PER_DAY;
}
export function suggestedTimeUnit(duration: number): TimeUnit {
@@ -29,20 +75,52 @@ export function suggestedTimeUnit(duration: number): TimeUnit {
return Number.isInteger(durationInDays(duration)) ? "days" : "hours";
}
export function durationInHours(duration: number): number {
return duration / 1000 / 60 / 60;
}
export function durationInDays(duration: number): number {
return duration / 1000 / 60 / 60 / 24;
}
export function relativeTime(date: Date) {
// Relative time functions
export function relativeTime(date: DateTimeInput) {
return dayjs(date).fromNow();
}
export function relativeTimeWithoutSuffix(date: DateTimeInput) {
return dayjs(date).fromNow(true);
}
export function timeFrom(
date: DateTimeInput,
referenceDate: DateTimeInput = new Date(),
) {
return dayjs(date).from(dayjs(referenceDate));
}
// Time manipulation functions
export function addTime(
date: DateTimeInput,
amount: number,
unit: dayjs.ManipulateType,
) {
return dayjs(date).add(amount, unit).toDate();
}
export function subtractTime(
date: DateTimeInput,
amount: number,
unit: dayjs.ManipulateType,
) {
return dayjs(date).subtract(amount, unit).toDate();
}
export function startOfDay(date: DateTimeInput) {
return dayjs(date).startOf("day").toDate();
}
export function startOfHour(date: DateTimeInput) {
return dayjs(date).startOf("hour").toDate();
}
export function daysAgo(count: number) {
const date = new Date();
date.setDate(date.getDate() - count);
return date.toISOString();
return dayjs().subtract(count, "day").toISOString();
}
// Date comparison functions
export function isAfter(date1: DateTimeInput, date2: DateTimeInput) {
return dayjs(date1).isAfter(dayjs(date2));
}