Time Management Web App: Human Friendly Scheduling

Shawn
9 min readOct 26, 2022

--

Our last big change is letting the user set up the cron string by picking a frequency (daily, weekly, monthly), what days the task happens, and the time it happens. The frontend should be able to convert to/from the cron strings.

Strategy

To make this happen, the cron string column in the table will be split into 3 columns:

  • Frequency: a dropdown saying whether the task happens daily at the same time; weekly on the same day of the week; or monthly on the same day of the month
  • Days: checkboxes representing either the days of the week, or days of the month. If the frequency is daily, this won’t show anything
  • Time: A datepicker set up to let us pick the time, in increments of 15 minutes

Each of these columns will be represented by an uncontrolled component (that is, one that doesn’t have its own internal state), all wrapped in one parent component. The parent component will handle both breaking down and rebuilding the cron string.

Our datepicker of choice will be the react-datepicker module. Install it as so:

npm install --save react-datepicker

Before making our changes, let’s have 3 example tasks, one for each frequency.

  • Daily: 25 18 * * *
  • Weekly: 26 18 * * 2
  • Monthly: 27 18 1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,28,29,30,31 * * 3

The CronDate Component

In a new file, cron_date.js, we begin the parent component, which we’ll call CronDate. Its first job will be receiving the cron string from the task and splitting it into the frequency, days, and time.

import {useState, useEffect} from 'react';const CronDate = props => {
const {cron_string} = props;
const [frequency, setFrequency] = useState('daily');
const [time, setTime] = useState(null);
const [days, setDays] = useState([]);
const allAsterisk = chars => {
const asterisks = chars.filter(c => c === '*');
return asterisks.length === chars.length;
}
const noneAsterisk = chars => {
const nonAsterisks = chars.filter(c => c !== '*');
return nonAsterisks.length === chars.length;
}
useEffect(() => {
if (cron_string === '') {
const now = new Date();
now.setHours(18);
now.setMinutes(0);
now.setSeconds(0);
now.setMilliseconds(0);
setTime(now);
} else {
const [minute, hour, ...rest] = cron_string.split(' ');
const now = new Date();
now.setMinutes(minute);
now.setHours(hour);
setTime(now);
}
}, []);
useEffect(() => {
const {cron_string} = props;
if (cron_string === '') {
return;
}
const [a, b, c, d, e] = cron_string.split(' '); let freq;
if (noneAsterisk([a, b]) && allAsterisk([c, d, e])) {
freq = 'daily';
} else if (noneAsterisk([a, b, e]) && allAsterisk([c, d])) {
freq = 'weekly';
} else if (noneAsterisk([a, b, c]) && allAsterisk([d, e])) {
freq = 'monthly';
} else {
console.error(`Invalid format for cron string: ${cron_string}`);
}
if (freq) {
setFrequency(freq);
}
}, []);

useEffect(() => {
if (frequency === 'weekly') {
const weeklyDays = cron_string.split(' ')[4];
if (weeklyDays === '*') {
setDays([0, 1, 2, 3, 4, 5, 6]);
} else if (weeklyDays.indexOf(',') > -1) {
setDays(weeklyDays.split(',').map(d => +d));
} else {
setDays([+weeklyDays]);
}
} else if (frequency === 'monthly') {
const monthlyDays = cron_string.split(' ')[2];
if (monthlyDays === '*') {
setDays([1, 2, 3, 4, 5, 6, 7, 8, 9, 10,
11, 12, 13, 14, 15, 16, 17, 18, 19, 20,
21, 22, 23, 24, 25, 26, 27, 28, 29, 30,
31]);
} else if (monthlyDays.indexOf(',') > -1) {
setDays(monthlyDays.split(',').map(d => +d));
} else {
setDays([+monthlyDays]);
}
}
}, [frequency]); return <>
<td>{frequency} {JSON.stringify(time)} {JSON.stringify(days)}</td>
</>
}
export default CronDate;

With three useEffects, there’s a lot to digest here.

The first one is for setting the time. If the cron string is an empty string, as is the initial case for a new task, we just set it to 6 PM. Otherwise we split the cron string, getting the pieces that correspond to the hour and minute. Then we create a Date object with those hours and minutes, and set our time state to that Date.

The second one is for setting the frequency. We default to daily for convenience, since a new task won’t have a frequency. After splitting the cron string to its 5 pieces, we check if it matches the format for a daily, weekly, or monthly cron.

Remember, a cron string is represented by 5 segments. From left to right, the segments represent:

  • The minute the cron runs (0–59)
  • The hour it runs (0–23)
  • The day of month it runs (1–31)
  • The month it runs (1–12)
  • The day of week it runs (0–6)

We are also limiting the cron expressions to just be numbers, commas, and asterisks. Commas are used to represent multiple values for a given segment. So if the minute value was 5,10,15 then the cron would run at the 6th, 11th, and 16th minutes of the hour.

With this knowledge we use noneAsterisk and allAsterisk to see if it meets these rules:

  • If the first two segments (minute and hour) aren’t asterisks (i.e., they’re something like 5 or 2,15and the rest are asterisks, then it’s a daily task. Example: going to lunch at the same time every day.
  • If the first, second, and last segments are non-asterisks, while the third and fourth are, then it’s a weekly cron. It runs at the same time of the day on select days of the week. Example: going to the gym at the same time a few days a week.
  • If the first, second, and third segments are non-asterisks, and the remaining segments are asterisks, then it’s a monthly task. It happens at the same time of the day, on the same day of the month. Example: paying a bill at on the 5th of each month.

The third useEffect is for setting the days. Note how, unlike the other two, this one has a dependency. When the frequency changes, this changes. That dependency is there because the frequency defaults to daily, but may change immediately after. This useEffect checks if the frequency is weekly or monthly, then grabs the appropriate segment of the cron string. From there it checks 3 things:

  • If the segment is an asterisk, then all of the days are meant to be used. Set days to an array of either 0–6 for weekly, or 1–31 for monthly.
  • If the segment has a comma, then it’s in the format of something like 1,2,5. Split the numbers and save them as an array.
  • Otherwise, the segment is just a single number. Directly save that number as a one-element array in state.

For purposes of debugging, we just return the state values.

Import the CronDate object into tasks.js . Over in TaskRow and NewTask we have a table row that shows the cron string in a text field. Replace that row with this:

<CronDate cron_string={task.cron_string} />

Here’s what it looks like with daily, weekly, and monthly tasks already added:

Table Restructure

Before adding the three child components, let’s restructure the table to handle the three columns. In TaskTable, replace the Cron String header with Frequency, Days, and Time:

<tr>
<th>Completed</th>
<th>Name</th>
<th>Frequency</th>
<th>Days</th>
<th>Time</th>

<th>Edit</th>
<th>Delete</th>
</tr>

Frequency Dropdown

The first of the child components is also the easiest: the dropdown to set whether it’s daily, weekly, or monthly. It’s relatively basic:

const FrequencyDropdown = props => {
const {frequency} = props;
const handleChange = e => {
props.handleFrequency(e.target.value);
}
return <select value={frequency} onChange={handleChange}>
<option value='daily'>Daily</option>
<option value='weekly'>Weekly</option>
<option value='monthly'>Monthly</option>
</select>
}

Since this component doesn’t have its own state, we include handleFrequency to update the frequency in CronDate.

const handleFrequency = freq => setFrequency(freq);...return <>
<td><FrequencyDropdown frequency={frequency} handleFrequency={handleFrequency} /></td>
</>

And here’s what it looks like now:

Days Checkboxes

The second component renders nothing if the task is daily, or the checkboxes if the task is weekly or monthly. We’re a little lazy here, because not every month has 31 days, but adjusting that would be more work than it’s worth right now.

const Days = props => {
const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'].map((d, i) => ({
id: i,
day: d
}));
const monthdays = [...Array(31)].map((u, i) => ({
id: i + 1,
day: (i + 1).toString()
}));
const {frequency, days} = props;
const handleChange = e => {
props.handleDayChange(+e.target.value);
}
if (frequency === 'daily') {
return null;
} else if (frequency === 'weekly') {
return weekdays.map(w => <div key={w.id}>
<input
type='checkbox'
value={w.id}
checked={days.includes(w.id)}
onChange={handleChange}
/> <label>{w.day}</label>
</div>);
} else if (frequency === 'monthly') {
return monthdays.map(m => <div key={m.id}>
<input
type='checkbox'
value={m.id}
checked={days.includes(m.id)}
onChange={handleChange}
/> <label>{m.day}</label>
</div>);
}
}

So what’s going on here? First we set up the arrays representing the days of the week, and the days of the month. [...Array(31)] is a handy trick to make an array of 31 elements, each being undefined. It’s i + 1 for the days because in cron strings they start at 1, not 0.

When rendering the component, we check if the days passed from CronDate includes the day the checkbox represents. In theory, it could have been written the other way around.

Back up in CronDate, add the handler and the row:

const handleDayChange = day => {
const oldDays = [...days];
const index = oldDays.findIndex(d => day === d);
if (index > -1) {
oldDays.splice(index, 1);
} else {
oldDays.push(day);
}
setDays(oldDays);
}
...<td><Days frequency={frequency} days={days} handleDayChange={handleDayChange} /></td>

The handler either removes the day if it’s already present, or adds it to the array otherwise.

The resulting table is bigger than my screen, but this screenshot provides enough to get the picture:

Choosing the Time

Finally, the component for showing the time at 15 minute intervals. The only wrinkle here is needing to import the Datepicker module first.

import Datepicker from 'react-datepicker';
import 'react-datepicker/dist/react-datepicker.css';
...const Timepicker = props => {
const {time} = props;
return <Datepicker
selected={time}
showTimeSelect
showTimeSelectOnly
timeIntervals={15}
timeCaption='Time'
dateFormat='h:mm aa'
onChange={props.handleChange}
/>
}

And over in CronDate:

const handleChange = date => setTime(date);...<td><Timepicker time={time} handleChange={handleChange} /></td>

Updating the Cron String

With the child components in place, updating the cron string is surprisingly simple. Add this in the CronDate component:

useEffect(() => {
if (time) {
const minute = time.getMinutes();
const hour = time.getHours();
let output = `${minute} ${hour} `;
if (frequency === 'daily') {
output += '* * *';
} else if (frequency === 'weekly') {
output += `* * ${days.join(',')}`;
} else if (frequency === 'monthly') {
output += `${days.join(',')} * *`;
}
props.handleChangeCron(output);
}
}, [frequency, days, time]);

This effect runs whenever the frequency, days, or time are changed. Every task will have the time of day set. For weekly and monthly tasks, we convert the array of days back to a comma-separated list and put them in the cron expression, in the proper place.

Then we update the cron string in the parent component, which holds the state about the entire task. In this case it’s the NewTask and TaskRow components. So add the handler for updating the cron, and pass the handler to CronDate:

const handleChangeCron = cron => setTask({...task, cron_string: cron});...<CronDate cron_string={task.cron_string} handleChangeCron={handleChangeCron} />

Now we have a fully functional dropdown/checkbox/timepicker combination for converting to and from cron expressions.

But there’s a bug! For a weekly or monthly task, change the dropdown to something else, then change it back. You’ll notice now the checkboxes are all checked. This is because the last useEffect we added modifies the cron string.

Backing Up the Cron String

The fix for this: keep a “backup copy” of the original cron string in TaskRow. When we change the cron string in CronDate, send up the change to the backup. When we click the Update button, set the actual cron string to the backup, then send to the database.

InTaskRow, therefore, remove the references to handleChangeCron and add these lines:

const [newCronString, setNewCronString] = useState(props.task.cron_string);
const saveNewCronString = cron => setNewCronString(cron);
...<CronDate cron_string={task.cron_string} saveNewCronString={saveNewCronString} />

Then in the update function, we need to set the cron string to the backup:

rest.cron_string = newCronString;

And finally, over in the useEffect in CronDate we added last, change the last line to call saveNewCronString instead of handleChangeCron.

But this change also affects NewTask, because we’ve changed the props that CronDate expects. Fortunately, since we’re not trying to preserve the original cron string (because there is no original cron string), we can simplify things. Simply rename handleChangeCron to saveNewCronString, and leave the rest of the function alone.

Conclusion

Although there’s plenty more to do with this project, we’ll wrap up the first iteration here. Here’s the final code for the project. See if you can modify it to do cool, new things. And if you find any bugs with the code please let me know! (You may notice the file names in the Gist start with client_ or server_. Those should be client/ and server/, but Gists don’t allow slashes in the names.)

https://gist.github.com/shawnco/cab58b64323de88fd1602fb144330dfa

Time Management Web App Series

--

--