Schedule Recurring Reminders with Native Notifications

Altrim Beqiri

Altrim Beqiri /

Last weekend I built a Mac Menu Bar application (see my previous article Building a Mac Menu Bar application with React, TypeScript and Electron). At the end of the article I listed some nice to have features for the application. One such feature on the list was the reminders. Since no routine app is complete without reminders I decided to work on it over this weekend. I won't go over every detail in this article, instead I will try to briefly explain some of the more interesting parts. You can check the feature diff on GitHub for the rest of the details.

To start where we left off initially we need to add a form inside our RoutineCreator.tsx, where we get the option to choose when we would want to be reminded and at what time. For the form I came up with the following UI as seen in the video below

In the form we have the option to choose how often to be reminded

  • Daily - option to select multiple days (e.g Monday, Wednesday and Friday)
  • Once a week - option to select only a specific day of a week (e.g Wednesday)

And for the second question at what time of the day to get reminded

  • Beginning of the day at 9:00
  • End of the day at 17:00 or
  • Pick a time - option to select or specify a custom time

In this project I am using React Hook Form as it provides nice and simple APIs when working with forms. To build the form first we start with the following interface

enum ScheduleFrequency {
  Daily = "Daily",
  Weekly = "Weekly",
}
type FormValues = {
  title: string;
  scheduleDays: string | string[];
  timeOfDay: "custom" | string;
  customTime: Date;
  scheduleFrequency: ScheduleFrequency;
};
// Default values used when initializing the form when component is first rendered
const defaultFormValues = {
  title: "",
  scheduleFrequency: ScheduleFrequency.Daily,
  scheduleDays: ["1", "2", "3", "4", "5"],
  timeOfDay: "9:00",
  customTime: new Date(),
};

Once we have the interface in place we initialize the form with the FormValues type and defaultFormValues using the useForm hook provided by React Hook Form.

useForm<FormValues>({
  defaultValues: defaultFormValues,
});

If we take a look at how our interface maps to the form this is how we compose the form

Sketch showing how the interface maps to the form

Now that we have the mapping to render the form for the first part we have the main RadioGroup for the scheduleFrequency and for the nested inputs we use watch() from useFormContext() to conditionally render the inputs based on the scheduleFrequency choice. For the sake of brevity I've taken out some parts from the following code but you can read the entire code in the NotificationScheduler.tsx component.

type Day = {
  value: string; // 0-6
  label: string; // Mo Tu We ...
};
// The structure we use to render the Checkbosex and Radio buttons
const days: Day[] = [
  { value: "1", label: "Mo" },
  { value: "2", label: "Tu" },
  { value: "3", label: "We" },
  { value: "4", label: "Th" },
  { value: "5", label: "Fr" },
  { value: "6", label: "Sa" },
  { value: "0", label: "Su" },
];

const NotificationScheduler: React.FC = () => {
  const { register, watch } = useFormContext(); // provided by React Hook Form

  // Watch when scheduleFrequency changes
  const { scheduleFrequency } = watch(["scheduleFrequency"]);
  const isDaily = scheduleFrequency === ScheduleFrequency.Daily;
  const isWeekly = scheduleFrequency === ScheduleFrequency.Weekly;

  <RadioGroup name="scheduleFrequency" defaultValue={ScheduleFrequency.Daily}>
    <Radio value={ScheduleFrequency.Daily} ref={register}>
      Daily On
    </Radio>
    {/* If the choice is .Daily render the Checkboxes */}
    {isDaily
      ? days.map((item) => (
          <Checkbox
            name="scheduleDays"
            key={item.value}
            value={item.value}
            ref={register}
            defaultChecked={parseInt(item.value) > 0 && parseInt(item.value) < 6}
          >
            {item.label}
          </Checkbox>
        ))
      : null}
    <Radio value={ScheduleFrequency.Weekly} ref={register}>
      Once a week on...
    </Radio>
    {/* If the choice is .Weekly render the Radio buttons */}
    {isWeekly ? (
      <RadioGroup name="scheduleDays">
        {days.map(({ value, label }) => (
          <Radio key={value} value={value} ref={register()}>
            {label}
          </Radio>
        ))}
      </RadioGroup>
    ) : null}
  </RadioGroup>;
};

Similarly for the second part we have the main Radio Group for the timeOfDay where we render the first two radio buttons with hard coded values for morning and evening, and the last option toggles the custom <DatePicker/> for the time selection.

const { timeOfDay } = watch(["timeOfDay"]);
const isCustomTime = timeOfDay === "custom";

<RadioGroup>
  <Radio name="timeOfDay" value="9:00" ref={register} defaultChecked>
    Beginning of the day (9:00)
  </Radio>
  <Radio name="timeOfDay" value="17:00" ref={register}>
    End of the day (17:00)
  </Radio>
  <Radio name="timeOfDay" value="custom" ref={register}>
    Pick a time
  </Radio>
  {isCustomTime ? (
    <Controller
      name="customTime"
      control={control}
      defaultValue={new Date()}
      render={(props) => (
        <DatePicker
          ref={register}
          name={props.name}
          selected={props.value}
          onChange={(date: Date) => props.onChange(date)}
          showTimeSelect
          showTimeSelectOnly
          timeIntervals={15}
          dateFormat="HH:mm"
          timeFormat="HH:mm"
          customInput={<TimePickerInput {...props} ref={register} />}
        />
      )}
    />
  ) : null}
</RadioGroup>;

Now that we have the form in place let's see how we can schedule the reminders and display them using native notifications. We are going to use Node Schedule for the notification scheduling. Node Schedule is a library that allows you to schedule jobs for execution at specific dates using cron-like expressions.

First off we start with JobScheduler.ts class, where we have a basic class that accepts a Routine in the constructor. The routine has all the information we need when scheduling the reminder. To schedule the reminder using Node Schedule is pretty straight forward. In the class we have a schedule method where we invoke schedule.scheduleJob() with a cron expression and then provide a callback that will display the notification when the job runs. And in the notify() method we send the Notification. Since we are inside Electron it will use the operating system's native notification APIs to display the notification.

import schedule from "node-schedule";
import { Routine } from "./database/RoutinesService";

export class JobScheduler {
  routine: Routine;

  constructor(routine: Routine) {
    this.routine = routine;
  }

  schedule(): schedule.Job | null {
    if (!this.routine.scheduleAt) {
      return null;
    }

    return schedule.scheduleJob(this.routine.scheduleAt, () => this.notify());
  }

  private notify() {
    new Notification(`${this.routine.emoji ?? ""} ${this.routine.title}`, {
      icon: "/assets/icon@2x.png",
      body: `It's time for your ${this.routine.title} routine!.`,
    });
  }
}

Now that we have the JobScheduler in place the only thing left to do is to schedule the notification when we create the routine. To see how we can achieve that we can take a look at the RoutineCreator.tsx component onSubmit function. The important part at this point is creating the cron expression based on the form choices. If we take a look at the cron format this is how the expression is constructed

Cron expression

Example if we select on the form that we want to be reminded Daily On: Mo We Fr and End of the day at 17:00 the cron expression would look like 00 17 * * 1,3,5. To get this from the form selection first we need to extract the time, so we check if the selection was custom or from the specified morning and evening values. And then based on the type we extract the hour and minute

// If we selected the pick a day option use `customTime` otherwise we use the static `timeOfDay` value
const time = timeOfDay === "custom" ? customTime : timeOfDay;
const [hour, minute] = getHourAndMinute(time);

// Here we extract hour and minute based on the type we provide
const getHourAndMinute = (time: string | Date): [string, string] => {
  // When we choose the static value like `9:00` we split the string on ':' and return the tuple
  if (typeof time === "string") {
    const [hour, minute] = time.split(":");
    return [hour, minute];
  }
  // Otherwise if it's a Date object that we get when we select the time we use `.getHours()` and `.getMinutes()`
  return [time.getHours().toString(), time.getMinutes().toString()];
};

After we extract hour and minute we need to get the schedule days, to do that we have the following function

const scheduleDays = getScheduleDays(form.scheduleDays);

// If we choose the daily option we get an array of strings otherwise for the weekly we get just one value
const getScheduleDays = (days: string | string[]): string => {
  // Based on the type we either return value for one day (e.g 3) or we return the list of days (eg. 1,2,3)
  return typeof days === "string" ? days : days.join(",");
};

Once we have both parts we can construct the entire expression that looks like the following

const scheduleAt = `${minute} ${hour} * * ${scheduleDays}`; // 00 17 * * 1,3,5;

Finally after we construct the scheduleAt expression, we save the routine and then use our JobScheduler we created earlier to schedule the notification.

const RoutineCreator: React.FC<Props> = () => {
  ...
  ...
  const onSubmit = async (form: FormValues) => {
    ...
    ...
    const routine: Routine = {
      title,
      color,
      scheduleAt, // the scheduleAt is used in our JobScheduler
      emoji: emoji?.emoji,
    };

    // Save the routine to the database
    await routinesService.createRoutine(routine);
    // Create the job for the routine
    const job = new JobScheduler(routine);
    // Schedule the notification
    job.schedule();

    // In the end reset the form and close it
    reset(defaultFormValues);
    onClose();
  };
};

If we now schedule a routine we will get a system notification to remind us at the specified time. You can watch the video below to see how that looks like in action.

That's it for this one. If you want to check how the application is build you can find the entire source code on GitHub.

One thing to keep in mind though is that scheduled jobs will only fire as long as the script is running, so if we schedule a routine and then close the application we lose all the notifications. To fix this what we can do is when the application starts we get the routines from the database and for each routine we schedule the notifications again, but I will leave this part to you or maybe for a future blog post :).

Happy coding!