Time Management Web App: Job Scheduling and Sockets

Shawn
6 min readOct 17, 2022

--

One of the key features of this time management app is a page that updates in real time when a task is scheduled to start. To do this, we need a task scheduling system on the backend that triggers when a task starts, and sends a socket message to the frontend with the data. This is where the node-schedule package comes in handy.

Strategy

The feature we’ll use of the package is the cron-based scheduling. It has a scheduleJob function that takes a job id, cron string, and a callback saying what should happen when it’s time for the task to run.

When a task is added, updated, or removed via the API, we need to update the schedule to match. Otherwise we have problems like non-existent tasks being triggered. It’s possible to add and remove individual jobs from the schedule. But for the sake of simplicity, we’ll be lazy and just do full clears and rebuilds of the schedule.

Task Scheduler Class

To keep our code tidy, the scheduler and related functions go into a dedicated class:

const schedule = require('node-schedule');class TaskSchedule {
constructor(taskFns) {
this.taskFns = taskFns;
process.on('SIGINT', async () => {
await schedule.gracefulShutdown();
process.exit(0);
});
}
addTask(task) {
const {id, cron_string} = task;
const job = schedule.scheduleJob(`job:${id}`, cron_string, () => {
// Put socket message here
});
return job;
}
async populateTasks() {
const tasks = await this.taskFns.getIncomplete();
return tasks.map(t => this.addTask(t));
}
clearTasks() {
Object.keys(schedule.scheduledJobs).map(k => {
const job = schedule.scheduledJobs[k];
job.cancel();
});
}
async rebuildSchedule() {
this.clearTasks();
await this.populateTasks();
}
}
module.exports = TaskSchedule;

There’s plenty going on here. In the constructor we receive a reference to the TaskFunctions object made in server/index.js . We also set up a listener where, if we end the script with Ctrl + C, it empties the schedule. populateTasks gets all tasks currently marked incomplete in the database, and adds them to the schedule one at a time via the addTask function. The clearTasks function goes through the tasks in the schedule and cancels them. You’ll note we’re mapping over Object.keys(schedule.scheduledJobs) . Somewhat counter-intuitively, the jobs are stored as an Object instead of an array. That’s why we need to loop over them this way. Finally, rebuildSchedule is the function that empties and re-populates the jobs.

Connecting to the API

The schedule class needs an addition to the TaskFunctions object to get incomplete tasks:

async getIncomplete() {
return Task.findAll({where: {completed: false}});
}

From here we can introduce the scheduler to the API. Over in index.js file:

const TaskSchedule = require('./task_schedule');...const TaskFunctions = {
...
}
const taskSchedule = new TaskSchedule(TaskFunctions);
taskSchedule.rebuildSchedule();

Then in the POST, PUT, and DELETE routes, we’ll add a call to rebuild the schedule. It’ll happen right before sending the result to the frontend. Here’s what it looks like for the POST route; the other two are nearly identical to this.

app.post('/api/task', async (req,res) => {
const result = await TaskFunctions.create(req.body);
await taskSchedule.rebuildSchedule();
res.send({result});
});

Because we’re calling rebuildSchedule right after the taskSchedule variable is declared, it’ll now put the tasks into the scheduler upon script startup.

Socket Communication

Now that we can schedule jobs, we need to send them to the frontend when they start. This is done via sockets. Like the scheduler, the socket code will go into its own object. We’ll simply call the file socket.js:

const {WebSocketServer, OPEN} = require('ws');class Socket {
constructor() {
this.wss = new WebSocketServer({port: 8080});
this.wss.on('connection', ws => console.log('Connected!'));
}
async sendTask(task) {
this.wss.clients.forEach(client => {
if (client.readyState === OPEN) {
const data = {
type: 'task_start',
task
};
client.send(JSON.stringify(data));
}
});
}
}
module.exports = Socket;

When the constructor is called for this class, a web socket server starts on port 8080. The sendTask function is used to send a task to the frontend. Import the class to index.js and pass it to the TaskSchedule object like so:

const Socket = require('./socket');...const socket = new Socket();
const taskSchedule = new TaskSchedule(TaskFunctions, socket);

And over in the task_schedule.js file itself:

...constructor(taskFns, socket) {
this.taskFns = taskFns;
this.socket = socket;
}
...addTask(task) {
const {id, cron_string} = task;
const job = schedule.scheduleJob(`job:${id}`, cron_string, () => {
this.socket.sendTask(task);
});
return job;
}

Socket Frontend

Now that the backend can send tasks through the web socket, we need a frontend that connects to the socket. For lack of a more creative name, we’ll call this file workspace.js. (If you can think of a better name, use it!) The Workspace component will make an API call to mark the task complete, so we first create the API object:

const url = 'http://localhost:3001/api/task';
const headers = {
'Content-Type': 'application/json'
};
const API = {
async update(id, body) {
const res = await fetch(`${url}/${id}`, {method: 'PUT', headers, body: JSON.stringify(body)});
const {result} = await res.json();
return result;
}
}

Then we make the component itself. We’ll skip explaining the event handlers and such, since we’ve covered that concept with previous components.

import {useState, useEffect} from 'react';...const Workspace = props => {
const [task, setTask] = useState({});
useEffect(() => {
const socket = new WebSocket('ws://localhost:8080');
socket.onmessage = e => {
try {
const data = JSON.parse(e.data);
setTask(data.task);
} catch (e) {
console.log(e);
}
}
}, []);
const handleClick = async e => {
const {id, completed} = task;
const result = await API.update(id, {completed: !completed});
setTask({...task, completed: !completed});
};
if (task.id) {
return <>
<table border='1'>
<thead>
<tr>
<th>Completed</th>
<th>Name</th>
</tr>
</thead>
<tbody>
<tr>
<td><input type='checkbox' name='completed' checked={task.completed} onChange={handleClick} /></td>
<td>{task.name}</td>
</tr>
</tbody>
</table>
</>
} else {
return <b>Waiting for a task to start</b>
}
}
export default Workspace;

Take note of the useEffect. This is where we open the web socket connection. When a message is received from the background, we parse it and set it as the currently active task. The handleClick event toggles the checkbox to mark the task as done or not.

You may notice the Waiting for a task to start. Basically, until the backend sends a task through the socket, it’ll just show that message. Truth be told, this a bit of a lazy shortcut. A more complete implementation would be able to fetch what task is currently active, or at least the most recent task. Perhaps we’ll come back and flesh this out in a future section.

To make the page accessible, go to index.js and add the route:

import Workspace from './workspace';...<Route path='workspace' element={<Workspace />} />

Now hop over to http://localhost:3000/workspace. Assuming you’re not going there right at the start of the minute, you should see the placeholder text:

At the start of the minute, this simple table appears:

Be careful: if you click the checkmark then refresh the page, the task won’t appear again. Remember the scheduler only has incomplete tasks. If you want to test this page fully works, then do the following

  1. Mark the task as complete and refresh
  2. When the next minute starts, note that the task doesn’t appear
  3. Go to the task list page and mark the task as not complete
  4. Come back to this page, and when the next minute starts, the task should reappear

Putting It Together

Now we have a basic page to show what the currently active task is. But the interface itself is pretty bland, and it’s not always clear what needs to be done. So in the next section we’ll add the ability to write notes for the task. Here’s what the code should look like at this point. (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.)

Time Management Web App Series

--

--