I built a task tracker, which is a simple to-do list app that runs in the terminal. The task-tracker keeps a list of things you need to do, and marks whether each one has been completed. The tasks are stored in a JSON file, so they're saved between runs - you can close the program and pick up where you left off.
To do this, I created a class called TaskList. When you create a TaskList object, it automatically creates an empty dictionary and a new JSON file to store it in, if the program has never been run before. Otherwise, it loads the dictionary from the existing JSON file and assigns it to the dictionary within the class:
class TaskList:
def __init__(self) -> None:
self.dictionary: dict[str, list[str]] = {}
if Path("to_do_list.json").exists():
with open("to_do_list.json") as f:
self.dictionary = json.load(f)
else:
with open("to_do_list.json", "w") as f:
json.dump(self.dictionary, f)
The TaskList lets you add tasks to the dictionary by creating a key with the name of the task and giving it a value of an empty list. The empty list simply means the task hasn't been completed yet:
def addTask(self, task: str) -> None:
self.dictionary[task] = []
with open("to_do_list.json", "w") as f:
json.dump(self.dictionary, f)
You can also delete tasks. I used try/except to handle the error that happens if the task doesn't exist:
def deleteTask(self, task: str) -> None:
try:
del self.dictionary[task]
with open("to_do_list.json", "w") as f:
json.dump(self.dictionary, f)
except KeyError:
print(f"Task {task} does not exist in the to-do list.")
You can mark tasks as completed too. This checks if the task is a key in the dictionary, and if it is, it sets its value to ["X"] so we can clearly see it's been done:
def completeTask(self, task: str) -> None:
if task in self.dictionary:
self.dictionary[task] = ["X"]
with open("to_do_list.json", "w") as f:
json.dump(self.dictionary, f)
Finally, we can see the list using the show method. This prints each key with its value next to it, line by line, so the user can clearly see what's in the task list:
def show(self) -> None:
for key in self.dictionary:
print(f"{self.dictionary[key]} : {key}")
For the program to work, I wanted it to keep asking the user what they'd like to do, and after every action to ask again, with the option to quit and stop the program. I used a while loop for this, and it also handles the input checking:
while True:
option_str = input(
"To see your current list, type 1\n"
"To add a task to your list, type 2\n"
"To delete a task from your list, type 3\n"
"To mark a task as completed, type 4\n"
"To quit, type 5\n"
"Type answer here: "
)
option_int = to_int(option_str)
if not is_valid_option(option_int):
continue
Once the user gives an answer, I wanted to make sure they actually typed a number (not something like "one" instead of 1), and that the number was between 1 and 5. To do that, I used two small functions - one to convert the answer into an integer, and one to check whether the number is valid.
The to_int function uses try/except. It tries to return the answer as an integer, but if that fails (because the user typed something that isn't a number), it prints "Not an int" and returns -1 to signal that the input was invalid:
def to_int(answer: Any) -> int:
try:
return int(answer)
except ValueError:
print("Not an int\n\n")
return -1
The is_valid_option function then checks whether the number is between 1 and 5:
def is_valid_option(option: int) -> bool:
return 1 <= option <= 5
So the flow is: first we try to convert the input to an int. If it works, we've got a real number, if not, to_int gives us -1. Then we pass that through is_valid_option to check it's between 1 and 5. Since -1 is outside that range, a non-number automatically counts as invalid. If the option is valid, we carry on, if not, I use continue to loop back and ask again.
If the user picks option 1 (see the current list), I just run the show method. (I created a variable called task_list of type TaskList just above the while loop. The empty print() calls are only there to add spacing so it's easier to read in the terminal.)
if option_int == 1:
print()
task_list.show()
print()
Each option links to the matching method. For example, to complete a task, we run completeTask:
elif option_int == 4:
print()
task: str = input("What task would you like to complete: ")
print()
task_list.completeTask(task)
print()
task_list.show()
print()
And if the user picks option 5, I use break to stop the while loop, which ends the program:
elif option_int == 5:
break
What I found difficult
The hardest part was getting the menu to keep working properly. If someone typed a letter instead of a number, my early versions would either crash or quietly do nothing. My first attempt used two separate loops and got into a real mess - bad input would slip through and the menu would behave strangely. What fixed it was rethinking it as a single loop that reads the choice, checks it's a valid number, and only then acts on it. Once it was one clean loop, all the weird behaviour went away.
What I would do differently
Right now, each task is stored as a key in a dictionary, with its "done or not" status as the value. That works, but I've since learned it might be cleaner to make a separate Task class that holds a task's description and whether it's done, and have TaskList hold a list of those Task objects. For a small app like this it isn't really necessary, but if I wanted to add more information to each task later, like a due date or a priority, giving a task its own class would make that much easier.
Top comments (0)