Skip to content

feat: add quiet hours settings page #9676

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

Merged
merged 19 commits into from
Sep 15, 2023
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
🧹
  • Loading branch information
aslilac committed Sep 14, 2023
commit c83255e724e8291f36da046d464528d63bd04422
95 changes: 11 additions & 84 deletions site/src/pages/UserSettingsPage/SchedulePage/ScheduleForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,25 +10,11 @@ import {
UpdateUserQuietHoursScheduleRequest,
UserQuietHoursScheduleResponse,
} from "api/typesGenerated";
import cronParser from "cron-parser";
import MenuItem from "@mui/material/MenuItem";
import { Stack } from "components/Stack/Stack";
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { timeZones } from "utils/timeZones";
import { timeZones, getPreferredTimezone } from "utils/timeZones";
import { Alert } from "components/Alert/Alert";
import { useQueryClient } from "@tanstack/react-query";

dayjs.extend(utc);
import advancedFormat from "dayjs/plugin/advancedFormat";
import duration from "dayjs/plugin/duration";
import { userQuietHoursScheduleKey } from "api/queries/settings";
dayjs.extend(advancedFormat);
dayjs.extend(duration);
dayjs.extend(timezone);
dayjs.extend(relativeTime);
import { timeToCron, quietHoursDisplay } from "utils/schedule";

export interface ScheduleFormValues {
startTime: string;
Expand All @@ -41,14 +27,14 @@ const validationSchema = Yup.object({
.test("is-time-string", "Time must be in HH:mm format.", (value) => {
if (value === "") {
return true;
} else if (!/^[0-9][0-9]:[0-9][0-9]$/.test(value)) {
}
if (!/^[0-9][0-9]:[0-9][0-9]$/.test(value)) {
return false;
} else {
const parts = value.split(":");
const HH = Number(parts[0]);
const mm = Number(parts[1]);
return HH >= 0 && HH <= 23 && mm >= 0 && mm <= 59;
}
const parts = value.split(":");
const HH = Number(parts[0]);
const mm = Number(parts[1]);
return HH >= 0 && HH <= 23 && mm >= 0 && mm <= 59;
}),
timezone: Yup.string().required(),
});
Expand All @@ -71,10 +57,8 @@ export const ScheduleForm: FC<React.PropsWithChildren<ScheduleFormProps>> = ({
onSubmit,
now,
}) => {
// Force a re-render every 15 seconds to update the "Next occurrence" field.
// The app re-renders by itself occasionally but this is just to be sure it
// doesn't get stale.
const [_, setTime] = useState<number>(Date.now());
// Update every 15 seconds to update the "Next occurrence" field.
const [, setTime] = useState<number>(Date.now());
useEffect(() => {
const interval = setInterval(() => setTime(Date.now()), 15000);
return () => {
Expand Down Expand Up @@ -159,9 +143,8 @@ export const ScheduleForm: FC<React.PropsWithChildren<ScheduleFormProps>> = ({
disabled
fullWidth
label="Next occurrence"
value={formatNextRun(
value={quietHoursDisplay(
form.values.startTime,

form.values.timezone,
now,
)}
Expand All @@ -181,59 +164,3 @@ export const ScheduleForm: FC<React.PropsWithChildren<ScheduleFormProps>> = ({
</Form>
);
};

const getPreferredTimezone = () => {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
};

const timeToCron = (time: string, tz?: string) => {
const [HH, mm] = time.split(":");
let prefix = "";
if (tz) {
prefix = `CRON_TZ=${tz} `;
}
return `${prefix}${mm} ${HH} * * *`;
};

// evaluateNextRun returns a Date object of the next cron run time.
const evaluateNextRun = (
time: string,
tz: string,
now: Date | undefined,
): Date => {
// The cron-parser package doesn't accept a timezone in the cron string, but
// accepts it as an option.
const cron = timeToCron(time);
const parsed = cronParser.parseExpression(cron, {
currentDate: now,
iterator: false,
utc: false,
tz,
});

return parsed.next().toDate();
};

const formatNextRun = (
time: string,
tz: string,
now: Date | undefined,
): string => {
const nowDjs = dayjs(now).tz(tz);
const djs = dayjs(evaluateNextRun(time, tz, now)).tz(tz);
let str = djs.format("h:mm A");
if (djs.isSame(nowDjs, "day")) {
str += " today";
} else if (djs.isSame(nowDjs.add(1, "day"), "day")) {
str += " tomorrow";
} else {
// This case will rarely ever be hit, as we're dealing with only times and
// not dates, but it can be hit due to mismatched browser timezone to cron
// timezone or due to daylight savings changes.
str += ` on ${djs.format("dddd, MMMM D")}`;
}

str += ` (${djs.from(now)})`;

return str;
};
4 changes: 2 additions & 2 deletions site/src/pages/UserSettingsPage/SchedulePage/SchedulePage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { FC } from "react";
import { Section } from "../../../components/SettingsLayout/Section";
import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Section } from "components/SettingsLayout/Section";
import { ScheduleForm } from "./ScheduleForm";
import { useMe } from "hooks/useMe";
import { Loader } from "components/Loader/Loader";
Expand All @@ -9,7 +10,6 @@ import {
userQuietHoursSchedule,
userQuietHoursScheduleKey,
} from "api/queries/settings";
import { ErrorAlert } from "components/Alert/ErrorAlert";

export const SchedulePage: FC = () => {
const me = useMe();
Expand Down
55 changes: 50 additions & 5 deletions site/src/utils/schedule.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,23 @@
import cronstrue from "cronstrue";
import dayjs, { Dayjs } from "dayjs";
import advancedFormat from "dayjs/plugin/advancedFormat";
import duration from "dayjs/plugin/duration";
import isToday from "dayjs/plugin/isToday";
import isTomorrow from "dayjs/plugin/isTomorrow";
import relativeTime from "dayjs/plugin/relativeTime";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import { Workspace } from "../api/typesGenerated";
import { Workspace } from "api/typesGenerated";
import { isWorkspaceOn } from "./workspace";
import cronParser from "cron-parser";

// 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(isToday);
dayjs.extend(isTomorrow);
dayjs.extend(relativeTime);
dayjs.extend(timezone);

/**
* @fileoverview Client-side counterpart of the coderd/autostart/schedule Go
* package. This package is a variation on crontab that uses minute, hour and
Expand Down Expand Up @@ -104,7 +106,7 @@ export const autostopDisplay = (workspace: Workspace): string => {
if (isShuttingDown(workspace, deadline)) {
return Language.workspaceShuttingDownLabel;
} else {
return deadline.tz(dayjs.tz.guess()).format("MMM D, YYYY h:mm A");
return deadline.tz(dayjs.tz.guess()).format("MMMM D, YYYY h:mm A");
}
} else if (!ttl || ttl < 1) {
// If the workspace is not on, and the ttl is 0 or undefined, then the
Expand Down Expand Up @@ -157,3 +159,46 @@ export const getMaxDeadlineChange = (
deadline: dayjs.Dayjs,
extremeDeadline: dayjs.Dayjs,
): number => Math.abs(deadline.diff(extremeDeadline, "hours"));

export const timeToCron = (time: string, tz?: string) => {
const [HH, mm] = time.split(":");
let prefix = "";
if (tz) {
prefix = `CRON_TZ=${tz} `;
}
return `${prefix}${mm} ${HH} * * *`;
};

export const quietHoursDisplay = (
time: string,
tz: string,
now: Date | undefined,
): string => {
// The cron-parser package doesn't accept a timezone in the cron string, but
// accepts it as an option.
const cron = timeToCron(time);
const parsed = cronParser.parseExpression(cron, {
currentDate: now,
iterator: false,
utc: false,
tz,
});

const day = dayjs(parsed.next().toDate()).tz(tz);
let display = day.format("h:mm A");

if (day.isToday()) {
display += " today";
} else if (day.isTomorrow()) {
display += " tomorrow";
} else {
// This case will rarely ever be hit, as we're dealing with only times and
// not dates, but it can be hit due to mismatched browser timezone to cron
// timezone or due to daylight savings changes.
display += ` on ${day.format("dddd, MMMM D")}`;
}

display += ` (${day.from(now)})`;

return display;
};
3 changes: 3 additions & 0 deletions site/src/utils/timeZones.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import tzData from "tzdata";

export const timeZones = Object.keys(tzData.zones).sort();

export const getPreferredTimezone = () =>
Intl.DateTimeFormat().resolvedOptions().timeZone;