task-cli is the first project I’m working on from roadmap.sh as part of their Backend Projects.
I decided to write it in JavaScript using Node.js rather than defaulting to my go-to of Python because I wanted to learn how those two work, given how ubiquitous they are.
The major objective here was to get started with JavaScript and NodeJs. While I don’t know that my code is quite idiomatic yet, at least I’m feeling more comfortable with the syntax and some of the build-in functionality.
One of the things I was excited to use was switch case statements. In previous Python code, for example, I’ve used (but tried to avoid!) series of if...else statements:
def switch(status):
if status == "todo":
pass
elif status == "mark-in-progress"
passAside
As of version 3.10, Python has a “structural pattern matching” feature introduced by PEP 636.
With JavaScript, I was able to apply similar logic without having a long series of comparison operations:
if (taskID == item.id) {
switch (status) {
case "todo":
item.task.status = "todo";
break;
case "mark-in-progress":
item.task.status = "in-progress";
break;
case "mark-done":
item.task.status = "done";
break;
default:
throw new Error("Mark status is one of [todo | mark-in-progress | mark-done]");One of the interesting things about this is the requirement to have a break statement for each case. Without them, the switch case will still work, but the default case always runs.
Another fun part of playing with JavaScript ES6 is the ability to use the class keyword. Coming from Python, this made class construction feel incredibly familiar. For example, the main class I used in task-cli was Tasks, defined as follows:
class Tasks {
constructor() {
this.taskPath = `${cwd()}/tasks.json`;
this.taskList = this.readTasksFile();
}If I’d written this in Python, I would have used very similar syntax:
class Tasks:
def __init__(self):
self.tasksPath = f"{path_function()}/tasks.json"
self.taskList = self.read_tasks_file()Here is an unordered, incomplete list of other things I learned:
- Where to find JavaScript and Node documentation
- That JavaScript has automatic semi-colon insertion!
- How to use
for...ofloops - General JavaScript syntax
- Using Node and NPM to create a package
- A better understanding of OOP
- How to use a bunch of build-in methods
- That
.js,.mjs, and.cjsfiles are the same but different (they indicate different versions of ECMAScript) - A thing called Object spreading, though I have to look into it more
Things I’m happy with
From a bird’s eye view, I am happy with a simple project layout. That is to say, I chose to have only two files for the program - task-cli.msj and index.mjs.
I can appreciate the utility of having separate files for each concern (I did this, for example, for my APL-API project). I suspect that this is a point of critical mass at which doing so makes sense; but for simple projects, the overhead of multiple files, imports flying all over the place, I’m not so sure it makes sense.
In a way, OOP helps with this. I could have written each method as its own file - addTask.mjs, updateTask.mjs, etc - and then imported them all into a single interface. But, in my mind, all of these operations are related, so it made more sense to include them as methods of the Task class itself.
The command-line interface was placed into a separate file (index.mjs) because that is definitely a different concern. While I could have placed the small amount of code at the bottom of the main file, having the user-interface separate from the code logic made far more sense to me.
In general I am happy with the project itself. It doesn’t have to be fancy, and it succeeded in the goal of helping me learn JS. Perhaps in the future I’ll come back to it and re-write the whole thing once I’m more proficient!
Things I want to improve
This section is, in a way, thinking out-loud. As I write this, things become clear in my mind and may end up fixing them. The problems described, therefore, apply to the first version of the app that I finished within the time constraints I gave myself. Some things may be fixed by the time I publish this, some things might not.
Argument parsing
At the moment, the argument parsing is rather brittle. In a future iteration I’d like more complex logic to manage, clean, and validate the input from users, along with better instructions when the input does not match the expected options.
For example, the updateTask function takes two-arguments: the id of the item being updated, and the text to insert. It’s ideal usage is as follows:
task-cli update 2 "Update this task"If the task is not found, the program will return a helpful message: Task not found (ID: 2). But if it is found, there is nothing stopping the using from adding more arguments:
task-cli update 2 "update this task" "ignore this part, I guess"The last argument is simply ignored, which isn’t a particularly clean pattern. It would be much better to show an error explaining why that is not a valid operation.
listTasks
At the moment, listTasks is a bit complex and messy. For one, I think I could use constants and ternary operators to simplify the code and make the error handling more dynamic.
So rather than this:
listTasks(status = null) {
if (!status) {
for (let item of this.taskList) {
console.log(
`${green}${item.task.description}\n${yellow}Status: ${item.task.status}\nTask ID: ${item.id}\n${reset}created ${item.task.createdAt} | updated ${item.task.updatedAt}\n`
);
}
} else {
if (!["started", "todo", "done"].includes(status)) {
console.log("[status] must be one of [todo | started | done]")
return
}
console.log(`Tasks ${status}:\n`);
for (let item of this.taskList) {
if (item.task.status == status) {
console.log(
`Task: ${green}${item.task.description}\n${yellow}Task ID: ${item.id}${reset}\ncreated ${item.task.createdAt} updated ${item.task.updatedAt}\n`
);
}
}
}
}I should have done this:
listTasks(status = null) {
const validStatuses = ["todo", "in-progress", "done"];
if (status && !validStatuses.includes(status)) {
console.log(`[status] must be one of ${validStatuses.join(" | ")}`);
return;
}
const tasksToDisplay = status
? this.taskList.filter(task => task.task.status === status)
: this.taskList;
if (tasksToDisplay.length === 0) {
console.log("No tasks found.");
return;
}
tasksToDisplay.forEach(item => {
console.log(
`${green}${item.task.description}\n${yellow}Status: ${item.task.status}\nTask ID: ${item.id}\n${reset}created ${item.task.createdAt} | updated ${item.task.updatedAt}\n`
);
});
}The main difference is the use of the validStatuses constant the the ternary operator in tasksToDisplay.
Let me break this apart:
The first thing is to have a constant at the beginning of the method that defines valid statuses. This makes it easier to see, rather than scavenging through all the lines of code, and certainly easier to update or expand in the future should I choose to do so. It also means I can use the validStatuses constant in both error handling and filtering:
const validStatuses = ["todo", "in-progress", "done"];
if (status && !validStatuses.includes(status)) {
console.log(`[status] must be one of ${validStatuses.join(" | ")}`);
return;The next set of lines involves the use of the ternary operator. In Python, a ternary operator is written as:
[do_this] if [condition] else [do_that]In JavaScript, the ternary operator is perhaps less human-readable, but has a very similar feel to it:
[condition] ? [do_this] : [do_that]Thus, I can create a constant that uses the build-in filtered method if there is a status option, else simply use the entire list:
const tasksToDisplay = status ? this.taskList.filter(task => task.task.status === status) : this.taskList;The result is that the logic to print the list to the terminal does not have to be repeated: whether there is a status or not, there is a single for loop that prints to the terminal:
for (let item of tasksToDisplay) {
console.log(`Task: ${green}${item.task.description}\n${yellow}Status: ${item.task.status}\n${yellow}Task ID: ${item.id}${reset}\ncreated ${item.task.createdAt} updated ${item.task.updatedAt}\n`
);This approach also works well for the markTask method.
STATUS
Rather than using individual strings everywhere for status - that is, using “todo”, “in-progress”, and “done” - and then matching them to arguments, it is better design to declare a constant that holds those values and re-use them.
This way I can modify the arguments themselves, avoid errors due to typos, and expand the list of available options easily. The original code looked like this:
//task-cli.mjs
case "todo":
item.task.status = "todo";
break;
case "mark-in-progress":
item.task.status = "in-progress";
break;
case "mark-done":
item.task.status = "done";//index.mjs
case "mark-in-progress":
taskCLI.markTask(args[1], args[0]);
break;
case "mark-done":
taskCLI.markTask(args[1], args[0]);A much better design is to declare the options as part of the Tasks class, and then re-use those:
class Tasks {
constructor() {
this.taskPath = `${cwd()}/tasks.json`;
this.taskList = this.readTasksFile();
this.utils = new Utils();
this.STATUS = {
TODO : "todo",
IN_PROGRESS: "in-progress",
DONE: "done"
};This then allows me to use this.STATUS.DONE, for example. Should anything need to change, there is one place to do it.
Long switch statement
Although I am attempting to keep my code DRY, there are certainly a few places where I repeat myself. The switch case in index.mjs is certainly one of those. I guess I was just excited to use switch case!
Nonetheless, I remember from many years ago I was playing with creating a text-adventure in Python, and rather than creating a series of if...else statements I used an Object with a hash-map to manage the directions where the player would go. The same design pattern probably works better here as well.
My first attempt was as follows:
const commands = {
add: createTask(args[1]),
update: updateTask(args[1], args[2]),
delete: deleteTask(args[1]),
todo: markTask(args[1], taskCLI.STATUS.TODO),
"mark-in-progress": markTask(args[1], taskCLI.STATUS.IN_PROGRESS),
"mark-done": markTask(args[1], taskCLI.STATUS.DONE),
list: listTasks(args[1]),
};This is very much Python-like syntax. The issue is that in JavaScript the code above calls all of those functions the moment that the commands object is defined, rather than using references to those functions to be called later.
The solution? Arrow functions without parameters:
const commands = {
add: () => taskCLI.createTask(args[1]),
update: () => taskCLI.updateTask(args[1], args[2]),
delete: () => taskCLI.deleteTask(args[1]),
todo: () => taskCLI.markTask(args[1], taskCLI.STATUS.TODO),
"mark-in-progress": () => taskCLI.markTask(args[1], taskCLI.STATUS.IN_PROGRESS),
"mark-done": () => taskCLI.markTask(args[1], taskCLI.STATUS.DONE),
list: () => taskCLI.listTasks(args[1]),
};
const command = commands[args[0]];
if (command) {
try {
command();
} catch (error) {
console.error("Error:", error.message);
console.log(usage);
}
} else {
console.log(usage);
}Now, when I run task-cli add "Some task", the following will happen:
- The “add” argument is passed as a key to
commandsand assigned tocommand - If
command(in this case, “add”) exists, call the value of command () => taskCLI.createTask("Some task")executes- The new task is added to the task list and the rest of the code runs