diff --git a/site/e2e/api.ts b/site/e2e/api.ts index 5e3fd2de06802..4d884a73cc1ac 100644 --- a/site/e2e/api.ts +++ b/site/e2e/api.ts @@ -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( diff --git a/site/e2e/tests/deployment/observability.spec.ts b/site/e2e/tests/deployment/observability.spec.ts index b15f192a241d7..ec807a67e2128 100644 --- a/site/e2e/tests/deployment/observability.spec.ts +++ b/site/e2e/tests/deployment/observability.spec.ts @@ -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( diff --git a/site/package.json b/site/package.json index 6483f207ac64e..1bc2a9d3e159a 100644 --- a/site/package.json +++ b/site/package.json @@ -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", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index d4be944fa491a..442e5511ce229 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -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 diff --git a/site/src/components/LastSeen/LastSeen.tsx b/site/src/components/LastSeen/LastSeen.tsx index 081e3ae624fa4..23e9a2076984e 100644 --- a/site/src/components/LastSeen/LastSeen.tsx +++ b/site/src/components/LastSeen/LastSeen.tsx @@ -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, "children"> { @@ -15,21 +13,25 @@ interface LastSeenProps export const LastSeen: FC = ({ 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"; diff --git a/site/src/components/Timeline/utils.ts b/site/src/components/Timeline/utils.ts index 214ee687239b1..61745e0a6f15a 100644 --- a/site/src/components/Timeline/utils.ts +++ b/site/src/components/Timeline/utils.ts @@ -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(); }; diff --git a/site/src/modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge.tsx b/site/src/modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge.tsx index f3c9c80d085fd..06a4d340c74f4 100644 --- a/site/src/modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge.tsx +++ b/site/src/modules/workspaces/WorkspaceDormantBadge/WorkspaceDormantBadge.tsx @@ -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 = ({ 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 ? ( 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 = ({ 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. diff --git a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx index 30ea89c72fdc6..99c6a1f10b3c9 100644 --- a/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx +++ b/site/src/pages/DeploymentSettingsPage/LicensesSettingsPage/LicenseCard.tsx @@ -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 = ({ 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()) ? ( Expired diff --git a/site/src/pages/DeploymentSettingsPage/optionValue.ts b/site/src/pages/DeploymentSettingsPage/optionValue.ts index 91821c998badf..18b43090409a7 100644 --- a/site/src/pages/DeploymentSettingsPage/optionValue.ts +++ b/site/src/pages/DeploymentSettingsPage/optionValue.ts @@ -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. } } diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx index 2651421a99d2a..1f27ec7f8412f 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx @@ -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 = ({ value, onChange }) => { @@ -79,10 +71,10 @@ export const DateRange: FC = ({ 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 = ({ 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(), }), }, diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index 081d49c68c331..37124431b4b41 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -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 { diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/WeekPicker.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/WeekPicker.tsx index f180dc2545a51..f2f3e95bf4a68 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/WeekPicker.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/WeekPicker.tsx @@ -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 = ({ value, onChange }) => { const anchorRef = useRef(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); diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts index 8268f1e0859a3..965d6c03d372f 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts @@ -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 }; }; diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts index d55f0b6c54fff..c0cae5d7e2bd1 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/useWorkspacesToBeDeleted.ts @@ -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; } }); diff --git a/site/src/pages/WorkspacePage/AppStatuses.tsx b/site/src/pages/WorkspacePage/AppStatuses.tsx index 5eaffe8e02bc1..921087b49c996 100644 --- a/site/src/pages/WorkspacePage/AppStatuses.tsx +++ b/site/src/pages/WorkspacePage/AppStatuses.tsx @@ -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 = ({ {latestStatus.message} - {formatDistance(new Date(latestStatus.created_at), comparisonDate, { - addSuffix: true, - })} + {timeFrom(new Date(latestStatus.created_at), comparisonDate)} @@ -216,13 +215,7 @@ export const AppStatuses: FC = ({ {displayStatuses && otherStatuses.map((status) => { const statusTime = new Date(status.created_at); - const formattedTimestamp = formatDistance( - statusTime, - comparisonDate, - { - addSuffix: true, - }, - ); + const formattedTimestamp = timeFrom(statusTime, comparisonDate); return (
= ({ 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. ), }); diff --git a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx index 63eb8bba27218..2d838ca9dc31d 100644 --- a/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceTopbar.stories.tsx @@ -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(); }, }, }, diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.test.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.test.tsx index 266f3871c0efa..1e9b50292887b 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.test.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.test.tsx @@ -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); diff --git a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx index aba52611a7122..813018f35543a 100644 --- a/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx +++ b/site/src/pages/WorkspaceSettingsPage/WorkspaceSchedulePage/WorkspaceScheduleForm.tsx @@ -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 = ({ <> 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; diff --git a/site/src/utils/time.ts b/site/src/utils/time.ts index e46ef276171f1..dcf34a036c388 100644 --- a/site/src/utils/time.ts +++ b/site/src/utils/time.ts @@ -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; +// Relative time functions +export function relativeTime(date: DateTimeInput) { + return dayjs(date).fromNow(); } -export function durationInDays(duration: number): number { - return duration / 1000 / 60 / 60 / 24; +export function relativeTimeWithoutSuffix(date: DateTimeInput) { + return dayjs(date).fromNow(true); } -export function relativeTime(date: Date) { - return dayjs(date).fromNow(); +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)); }