The menu¶
Goal: build todo.py, the part of the app the user sees: the table of tasks, adding, finishing and deleting, with a save after every change.
Why this matters¶
The storage module is finished and tested. Everything in this lesson sits on top of it and never touches the file directly. That is what the plan promised, and it pays off now: the menu code can concentrate entirely on the user, and it is mostly things you have built before, assembled with more care. At the end of this lesson the app works.
The whole program¶
Here is todo.py, complete. As with the storage module, read the sections below alongside it:
"""The to-do app. Run this file.
Needs todo_storage.py and helpers.py in the same folder.
"""
from datetime import date
from helpers import ask_for_number, ask_for_date, ask_yes_no
from todo_storage import load_tasks, save_tasks
def due_date(task):
"""Return the part of a task to sort by."""
return task["due"]
def show_tasks(tasks, today):
"""Print the tasks as a table, marking done and overdue ones."""
if len(tasks) == 0:
print("Nothing to do!")
return
print(f"{'#':>2} {'':<4}{'Title':<28}{'Due':<12}{'Pri':>3}")
number = 1
for task in tasks:
if task["done"]:
mark = "[x]"
else:
mark = "[ ]"
line = f"{number:>2} {mark:<4}{task['title']:<28}{str(task['due']):<12}{task['priority']:>3}"
if not task["done"] and task["due"] < today:
line += " OVERDUE"
print(line)
number += 1
def add_task(tasks):
"""Ask for a title, due date and priority, and add the new task to the list."""
title = input("Title: ").strip()
while title == "" or "|" in title:
title = input("The title cannot be empty or contain |. Title: ").strip()
due = ask_for_date("Due (YYYY-MM-DD): ")
priority = ask_for_number("Priority, 1 (urgent) to 3 (whenever): ")
while priority < 1 or priority > 3:
priority = ask_for_number("Please choose 1, 2 or 3: ")
tasks.append({"title": title, "due": due, "priority": priority, "done": False})
tasks.sort(key=due_date)
print(f"Added: {title}")
def choose_task(tasks, prompt):
"""Ask for a task number and return its index in the list, or None if there is no such task."""
number = ask_for_number(prompt)
if number >= 1 and number <= len(tasks):
return number - 1
print(f"There is no task {number}.")
return None
def finish_task(tasks, today):
"""Ask which task is done and mark it done."""
if len(tasks) == 0:
print("Nothing to finish!")
return
show_tasks(tasks, today)
index = choose_task(tasks, "Which number is done? ")
if index is None:
return
tasks[index]["done"] = True
print(f"Finished: {tasks[index]['title']}")
def delete_task(tasks, today):
"""Ask which task to delete, confirm, and remove it."""
if len(tasks) == 0:
print("Nothing to delete!")
return
show_tasks(tasks, today)
index = choose_task(tasks, "Which number should be deleted? ")
if index is None:
return
if ask_yes_no(f"Delete '{tasks[index]['title']}'?"):
deleted = tasks.pop(index)
print(f"Deleted: {deleted['title']}")
else:
print("Kept it.")
def show_menu():
"""Print the menu and return the user's choice."""
print()
print("1. Show tasks")
print("2. Add a task")
print("3. Finish a task")
print("4. Delete a task")
print("5. Quit")
return input("Choose: ")
def main():
"""Load the tasks, run the menu until the user quits, saving after every change."""
tasks = load_tasks()
tasks.sort(key=due_date)
today = date.today()
print(f"Loaded {len(tasks)} tasks.")
while True:
choice = show_menu()
if choice == "1":
show_tasks(tasks, today)
elif choice == "2":
add_task(tasks)
save_tasks(tasks)
elif choice == "3":
finish_task(tasks, today)
save_tasks(tasks)
elif choice == "4":
delete_task(tasks, today)
save_tasks(tasks)
elif choice == "5":
print("Goodbye.")
break
else:
print("Please choose 1 to 5.")
if __name__ == "__main__":
main()
And here is a session with it, run on 19 September 2026 with no tasks.txt yet. Everything after a prompt is typed:
Loaded 0 tasks.
1. Show tasks
2. Add a task
3. Finish a task
4. Delete a task
5. Quit
Choose: 2
Title: Buy milk
Due (YYYY-MM-DD): 2026-09-21
Priority, 1 (urgent) to 3 (whenever): 2
Added: Buy milk
1. Show tasks
2. Add a task
3. Finish a task
4. Delete a task
5. Quit
Choose: 2
Title: Call the dentist
Due (YYYY-MM-DD): 18/09/2026
Please use the form YYYY-MM-DD, for example 2026-12-25.
Due (YYYY-MM-DD): 2026-09-18
Priority, 1 (urgent) to 3 (whenever): 0
Please choose 1, 2 or 3: 3
Added: Call the dentist
1. Show tasks
2. Add a task
3. Finish a task
4. Delete a task
5. Quit
Choose: 2
Title: Finish Module 9
Due (YYYY-MM-DD): 2026-10-03
Priority, 1 (urgent) to 3 (whenever): 1
Added: Finish Module 9
1. Show tasks
2. Add a task
3. Finish a task
4. Delete a task
5. Quit
Choose: 1
# Title Due Pri
1 [ ] Call the dentist 2026-09-18 3 OVERDUE
2 [ ] Buy milk 2026-09-21 2
3 [ ] Finish Module 9 2026-10-03 1
1. Show tasks
2. Add a task
3. Finish a task
4. Delete a task
5. Quit
Choose: 3
# Title Due Pri
1 [ ] Call the dentist 2026-09-18 3 OVERDUE
2 [ ] Buy milk 2026-09-21 2
3 [ ] Finish Module 9 2026-10-03 1
Which number is done? 1
Finished: Call the dentist
1. Show tasks
2. Add a task
3. Finish a task
4. Delete a task
5. Quit
Choose: 4
# Title Due Pri
1 [x] Call the dentist 2026-09-18 3
2 [ ] Buy milk 2026-09-21 2
3 [ ] Finish Module 9 2026-10-03 1
Which number should be deleted? 2
Delete 'Buy milk'? (y/n) y
Deleted: Buy milk
1. Show tasks
2. Add a task
3. Finish a task
4. Delete a task
5. Quit
Choose: 5
Goodbye.
Run it again and it starts with Loaded 2 tasks. Open tasks.txt and there they are, in the format from the plan.
Now the pieces.
The table¶
show_tasks() is table.py from the planning lesson with two additions. The [x] and [ ] column comes from the
planning exercise. The OVERDUE mark is new: a task is overdue when it is not done and its due date is before
today, which is one comparison between two dates, task["due"] < today. The mark is added to the end of the line
with +=, and only then is the line printed.
The function takes today as a parameter rather than calling date.today() itself, as the plan said. main() works
out the date once and passes it along. That keeps the function honest: give it any list and any date, and it prints
the right table, which makes it easy to test.
Keeping the list in order¶
The plan said the table is sorted by due date. The obvious way is for task in sorted(tasks, key=due_date): inside
show_tasks(). But look at what the user does next: Which number is done? 1. If the table were sorted only for
display, task 1 on screen would not be tasks[0] in the list, and the wrong task would be finished. Numbers on screen
must match positions in the list.
So instead the list itself is kept sorted. main() sorts it once after loading, with tasks.sort(key=due_date), and
add_task() sorts again after every append. The .sort() method from Module 4 takes the same key= as sorted(),
and sorts in place. Between changes, the list never moves, so the numbers the user sees are always right.
Adding a task¶
add_task() asks three questions, and each one is checked in the way that suits it:
- The title is checked with a
whileloop and anif-style condition: it must not be empty, and it must not contain|, because that would break the file format. This is the promise the storage lesson made: the app will not let the user type one..strip()means a title of spaces counts as empty. - The date comes from
ask_for_date(), which loops on its own until it gets a real date. - The priority comes from
ask_for_number(), and then awhileloop rejects anything outside 1 to 3. This is the rule from Module 7 again:try/exceptfor input Python cannot convert, andiforwhilefor values Python accepts but the app does not.
Then a dictionary with the four keys is appended, with "done": False, because a new task is never done, and the list
is sorted.
Choosing a task¶
Finishing and deleting both begin the same way: show the table, ask for a number, check it is in range. Rather than
write that twice, the plan gains a function it did not have, choose_task(). Plans are allowed to change, and this is
the most common way they do: two functions turn out to share a piece, and the piece gets a name.
choose_task() returns the index of the chosen task, which is the number the user typed minus one. When the
number is out of range, it prints a message and returns None, the nothing value from Module 5. The caller checks
for that with if index is None:. Checking for None is done with is, not ==. It reads well, if index is
None, and it is the one place in Python where you will see is used for a comparison at this stage of the course.
Finishing and deleting¶
With choose_task() doing the asking, both functions are short. finish_task() sets tasks[index]["done"] = True.
Read that from left to right: the task at that index, then its "done" key, set to True. It changes the dictionary
inside the list, which is exactly what you want.
delete_task() adds one step: ask_yes_no() confirms before .pop() removes the task, because deleting is the
one action in the app that cannot be undone. The f-string in the question shows the title, so the user is confirming
a specific task, not a number they may have mistyped.
Both functions check for an empty list first and return early, the guard clause pattern from Module 5. There is no point showing an empty table and asking for a number.
main()¶
main() is the Module 6 to-do loop, grown up. It loads and sorts the tasks, works out today's date, and runs the
menu. The important difference is when it saves. Module 6 saved on quit, and the lesson admitted that was fragile.
This version calls save_tasks(tasks) immediately after every action that changes the list: add, finish, delete.
Close the window at any moment and nothing is lost. The save runs even if the user cancelled a delete, which costs
nothing, because saving an unchanged list produces an unchanged file.
The last two lines, if __name__ == "__main__": main(), are why main() is a function. Run the file and the app
starts. Import the file, to test show_tasks() on a list of your own, and it does not.
Try it¶
Delete tasks.txt, run the app, and add four tasks in a random order of dates, one of them in the past. Check that the
table is sorted and the past one is marked. Then quit without using option 5, by closing the window or pressing
Ctrl+C, run the app again, and confirm that all four are still there. Finally, edit tasks.txt by hand and break
one line, then start the app and watch the storage module report it.
Common mistakes¶
Finishing task 2 marks a different task
The table is sorted differently from the list. Do not sort inside show_tasks(); keep the list itself sorted, in
main() after loading and in add_task() after appending.
TypeError: '<' not supported between instances of 'datetime.date' and 'str'
A due date is text somewhere. Every date in the list must be a real date, which ask_for_date() and
line_to_task() both guarantee. Check that new tasks are built with the result of ask_for_date(), not input().
The new task appears but is gone next time
save_tasks() is not being called after the change. Every branch of the menu that changes the list must save.
TypeError: show_tasks() missing 1 required positional argument: 'today'
A call has forgotten today. finish_task() and delete_task() take today only so that they can pass it on to
show_tasks().
The OVERDUE mark appears on done tasks
The condition needs both parts: not task["done"] and task["due"] < today. A finished task is never overdue.
Nothing happens when I run todo.py
The last two lines are missing, or main() is defined but never called. Without if __name__ == "__main__":
main(), the file defines its functions and stops.
Exercises¶
Each of these adds a feature to todo.py. Add a menu option for the ones that need it, renumber Quit, and remember
to save after any change to the list.
- Startup summary. Instead of
Loaded 3 tasks., the app should start with a line such as3 tasks to do, 1 overdue., counting only tasks that are not done. Write a function that returns both numbers. - Clear done tasks. Add an option that removes every done task at once, after showing how many there are and asking for confirmation. Be careful not to remove items from a list while looping over that same list.
- Due soon. Add an option that lists the open tasks due within the next seven days, including today, each with
todayorin N daysafter it.timedeltafrom Module 8 gives you the cut-off date. - Postpone. Add an option that asks which task to postpone and by how many days, and moves its due date. The list must be sorted again afterwards, and the message should show the new date.
Solution 1
# A new function for todo.py, and one changed line in main().
def count_open(tasks, today):
"""Return how many tasks are not done and how many of those are overdue."""
open_count = 0
overdue = 0
for task in tasks:
if not task["done"]:
open_count += 1
if task["due"] < today:
overdue += 1
return open_count, overdue
# In main(), replace the Loaded line with:
# open_count, overdue = count_open(tasks, today)
# print(f"{open_count} tasks to do, {overdue} overdue.")
The function returns two values, which Module 5 showed Python bundles into a tuple, and main() unpacks them
into two names in one line.
Solution 2
# A new function for todo.py, plus a menu option that calls it and saves.
def clear_done(tasks):
"""Remove every task that is done, after confirming."""
done_tasks = []
for task in tasks:
if task["done"]:
done_tasks.append(task)
if len(done_tasks) == 0:
print("No done tasks to clear.")
return
if not ask_yes_no(f"Remove {len(done_tasks)} done tasks?"):
print("Kept them.")
return
for task in done_tasks:
tasks.remove(task)
print(f"Removed {len(done_tasks)} tasks.")
The done tasks are collected into a separate list first, and only then removed with .remove(). Removing items
from tasks while the for loop is walking through tasks skips items, because the loop's position moves on
while the list shifts underneath it. Two passes avoid the problem.
Solution 3
# A new function for todo.py, plus a menu option that calls it. Needs timedelta imported.
from datetime import timedelta
def show_due_soon(tasks, today):
"""Print the open tasks due within the next 7 days, including today."""
limit = today + timedelta(days=7)
found = 0
for task in tasks:
if not task["done"] and task["due"] >= today and task["due"] <= limit:
days = (task["due"] - today).days
if days == 0:
when = "today"
else:
when = f"in {days} days"
print(f"{task['title']} ({when})")
found += 1
if found == 0:
print("Nothing due in the next 7 days.")
limit is today plus seven days, and a task qualifies when its due date is between today and the limit, both
included. Add timedelta to the from datetime import line at the top of todo.py.
Solution 4
# A new function for todo.py, plus a menu option that calls it and saves. Needs timedelta imported.
from datetime import timedelta
def postpone_task(tasks, today):
"""Ask which task to postpone and by how many days, and move its due date."""
if len(tasks) == 0:
print("Nothing to postpone!")
return
show_tasks(tasks, today)
index = choose_task(tasks, "Which number should be postponed? ")
if index is None:
return
days = ask_for_number("By how many days? ")
while days < 1:
days = ask_for_number("At least 1 day: ")
task = tasks[index]
task["due"] = task["due"] + timedelta(days=days)
tasks.sort(key=due_date)
print(f"Moved '{task['title']}' to {task['due']}.")
The task is put in its own variable before the sort. After tasks.sort(), tasks[index] may be a different
task, because the postponed one has moved. Holding on to the dictionary itself avoids the mix-up, since sorting
moves the dictionaries around but does not change them.
Summary¶
todo.pynever touches the file. It importsload_tasks()andsave_tasks()and works on the list.- Keep the list sorted, with
tasks.sort(key=due_date)after loading and after adding, so that the numbers on screen match positions in the list. - Check input with the right tool:
ask_for_date()andask_for_number()for conversion,whileandiffor range. choose_task()returns an index orNone. Test forNonewithis.- Save after every change, not on quit. Put the menu loop in
main()and call it underif __name__ == "__main__":.