Finishing touches¶
Goal: polish the app with an edit option and friendlier dates, then look back at how it was built and which lesson each piece came from.
Why this matters¶
The app works. This lesson is about the difference between a program that works and one you would happily use every day, which is usually a handful of small things: a date that says tomorrow instead of making you count, a way to fix a typo without deleting and re-adding, a greeting that tells you what needs doing. Then, with the project finished, it is worth stopping to see how much of the course went into it.
Saying when¶
The table shows a due date and an OVERDUE mark. What the user actually wants to know is how soon. That is the
describe() function from the Module 8 countdown, adapted:
def describe_due(task, today):
"""Return a short phrase saying when the task is due, or an empty string if it is done."""
if task["done"]:
return ""
days = (task["due"] - today).days
if days == 0:
return "today"
elif days == 1:
return "tomorrow"
elif days > 1:
return f"in {days} days"
elif days == -1:
return "OVERDUE by 1 day"
else:
return f"OVERDUE by {-days} days"
It returns a string rather than printing, so that show_tasks() can drop it into a new When column at the end of
each row. Done tasks get an empty string, so their row simply ends early. The OVERDUE mark now comes with a
number, and the separate overdue check in show_tasks() is gone, because this function covers it.
Editing a task¶
The plan's not now list had editing on it. It turns out to be cheap, because every piece already exists. Here is the function:
def edit_task(tasks, today):
"""Ask which task to edit, then offer to change each of its details in turn."""
if len(tasks) == 0:
print("Nothing to edit!")
return
show_tasks(tasks, today)
index = choose_task(tasks, "Which number should be edited? ")
if index is None:
return
task = tasks[index]
new_title = input(f"New title (Enter to keep '{task['title']}'): ").strip()
if new_title != "" and "|" not in new_title:
task["title"] = new_title
if ask_yes_no(f"Change the due date from {task['due']}?"):
task["due"] = ask_for_date("New due date (YYYY-MM-DD): ")
if ask_yes_no(f"Change the priority from {task['priority']}?"):
task["priority"] = ask_for_priority("New priority, 1 to 3: ")
tasks.sort(key=due_date)
print(f"Updated: {task['title']}")
The shape is finish_task() up to the if index is None: line, then one question per detail. Each question shows the
current value and makes keeping it the easy option: press Enter for the title, or answer n for the date and
priority. That is a small courtesy that makes the difference between an edit feature people use and one they avoid.
Two details to notice. task = tasks[index] holds the dictionary in its own name, so the function can change
task["title"] and the change lands in the list, because the name and the list item are the same dictionary. And the
list is sorted at the end, because a new due date may move the task.
Small functions for the rest¶
While adding edit_task(), two pieces of add_task() were needed a second time: asking for a title and asking for a
priority. Following the rule from the last lesson, they became ask_for_title() and ask_for_priority(), and
add_task() shrank to five lines. The startup line now uses count_open() from the last exercises, so the app opens
with 3 tasks to do, 1 overdue. instead of a count of everything.
Here is the finished program:
"""The to-do app, finished. 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 describe_due(task, today):
"""Return a short phrase saying when the task is due, or an empty string if it is done."""
if task["done"]:
return ""
days = (task["due"] - today).days
if days == 0:
return "today"
elif days == 1:
return "tomorrow"
elif days > 1:
return f"in {days} days"
elif days == -1:
return "OVERDUE by 1 day"
else:
return f"OVERDUE by {-days} days"
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
def show_tasks(tasks, today):
"""Print the tasks as a table, with a column saying when each one is due."""
if len(tasks) == 0:
print("Nothing to do!")
return
print(f"{'#':>2} {'':<4}{'Title':<28}{'Due':<12}{'Pri':>3} {'When'}")
number = 1
for task in tasks:
if task["done"]:
mark = "[x]"
else:
mark = "[ ]"
print(f"{number:>2} {mark:<4}{task['title']:<28}{str(task['due']):<12}{task['priority']:>3} {describe_due(task, today)}")
number += 1
def ask_for_title(prompt):
"""Keep asking until the user types a title that is not empty and has no | in it."""
title = input(prompt).strip()
while title == "" or "|" in title:
title = input("The title cannot be empty or contain |. " + prompt).strip()
return title
def ask_for_priority(prompt):
"""Keep asking until the user types 1, 2 or 3."""
priority = ask_for_number(prompt)
while priority < 1 or priority > 3:
priority = ask_for_number("Please choose 1, 2 or 3: ")
return priority
def add_task(tasks):
"""Ask for a title, due date and priority, and add the new task to the list."""
title = ask_for_title("Title: ")
due = ask_for_date("Due (YYYY-MM-DD): ")
priority = ask_for_priority("Priority, 1 (urgent) to 3 (whenever): ")
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 edit_task(tasks, today):
"""Ask which task to edit, then offer to change each of its details in turn."""
if len(tasks) == 0:
print("Nothing to edit!")
return
show_tasks(tasks, today)
index = choose_task(tasks, "Which number should be edited? ")
if index is None:
return
task = tasks[index]
new_title = input(f"New title (Enter to keep '{task['title']}'): ").strip()
if new_title != "" and "|" not in new_title:
task["title"] = new_title
if ask_yes_no(f"Change the due date from {task['due']}?"):
task["due"] = ask_for_date("New due date (YYYY-MM-DD): ")
if ask_yes_no(f"Change the priority from {task['priority']}?"):
task["priority"] = ask_for_priority("New priority, 1 to 3: ")
tasks.sort(key=due_date)
print(f"Updated: {task['title']}")
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. Edit a task")
print("4. Finish a task")
print("5. Delete a task")
print("6. 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()
open_count, overdue = count_open(tasks, today)
print(f"{open_count} tasks to do, {overdue} overdue.")
while True:
choice = show_menu()
if choice == "1":
show_tasks(tasks, today)
elif choice == "2":
add_task(tasks)
save_tasks(tasks)
elif choice == "3":
edit_task(tasks, today)
save_tasks(tasks)
elif choice == "4":
finish_task(tasks, today)
save_tasks(tasks)
elif choice == "5":
delete_task(tasks, today)
save_tasks(tasks)
elif choice == "6":
print("Goodbye.")
break
else:
print("Please choose 1 to 6.")
if __name__ == "__main__":
main()
And a session with it, run on Sunday 20 September 2026 with four saved tasks:
3 tasks to do, 1 overdue.
1. Show tasks
2. Add a task
3. Edit a task
4. Finish a task
5. Delete a task
6. Quit
Choose: 1
# Title Due Pri When
1 [x] Call the dentist 2026-09-18 3
2 [ ] Water the plants 2026-09-18 3 OVERDUE by 2 days
3 [ ] Buy milk 2026-09-21 2 tomorrow
4 [ ] Finish Module 9 2026-10-03 1 in 13 days
1. Show tasks
2. Add a task
3. Edit a task
4. Finish a task
5. Delete a task
6. Quit
Choose: 3
# Title Due Pri When
1 [x] Call the dentist 2026-09-18 3
2 [ ] Water the plants 2026-09-18 3 OVERDUE by 2 days
3 [ ] Buy milk 2026-09-21 2 tomorrow
4 [ ] Finish Module 9 2026-10-03 1 in 13 days
Which number should be edited? 3
New title (Enter to keep 'Buy milk'): Buy oat milk
Change the due date from 2026-09-21? (y/n) n
Change the priority from 2? (y/n) y
New priority, 1 to 3: 1
Updated: Buy oat milk
1. Show tasks
2. Add a task
3. Edit a task
4. Finish a task
5. Delete a task
6. Quit
Choose: 6
Goodbye.
The file is todo_final.py here only so that the last lesson's version stays on its own page. In your project folder it
is simply todo.py, and todo_storage.py and helpers.py are unchanged since the storage lesson.
Where it all came from¶
Here is the finished app, piece by piece, against the lesson that taught each piece:
| Piece of the app | What it uses | Module |
|---|---|---|
The menu loop and break |
while True, if/elif on the choice |
1 and 3 |
[x] marks, OVERDUE by 2 days |
f-strings, string methods, .strip() and .lower() in helpers |
2 |
| A task | a dictionary with four keys | 4 |
The list of tasks, .pop(), .sort() |
list methods | 4 |
One job per function, docstrings, main() |
functions, guard clauses, return values | 5 |
load_tasks() and save_tasks() |
open(), modes, with, one item per line |
6 |
Skipping bad lines, ask_for_number() |
try/except, naming the error, asking again |
7 |
Due dates, tomorrow, in 13 days |
datetime, timedelta, fromisoformat() |
8 |
helpers.py, todo_storage.py |
your own modules, if __name__ == "__main__": |
8 |
| The table, sorting by date | sorted() and .sort() with key=, f-string widths |
9 |
Nothing in it is advanced. It is all the ordinary parts of the course, assembled with a plan. That is what programming mostly is, and it is why the planning lesson came before any code.
What you would add next¶
The not now list from the planning lesson is still there, and now you can judge each item by what it would cost:
- Categories or tags need a fifth part in every task, a change to the file format in two functions, and a column in the table. Cheap, and the last exercise below walks through it.
- Reminders need the program to be running at the right moment, or to be started by the operating system on a schedule. That is a different kind of program, and worth looking into once you have finished the course.
- Colours in the terminal are possible, with codes the terminal understands, but they behave differently on different systems. A search for python colorama is the usual starting point.
- Several lists, for work and home, need a filename per list and a way to choose one. Every function that takes
filenamealready supports it.
Try it¶
Use the app for real for a week. Put your actual tasks in it, and run it every morning. Nothing teaches you what a program needs like using it, and by the end of the week you will have your own list of finishing touches. Add one.
Common mistakes¶
Editing the title changes nothing
The function changed a copy of the title, not the task. task = tasks[index] must refer to the dictionary in
the list, and the change must be task["title"] = new_title, not title = new_title.
After editing the date, the task numbers are wrong
The list was not sorted after the edit. Any change to a due date needs tasks.sort(key=due_date) afterwards.
The When column shows 'in -2 days'
The negative branch is missing or in the wrong order. The if chain must test days == 0, days == 1, days > 1,
days == -1, and finally everything else, and the negative branches use -days to show a positive number.
Pressing Enter to keep the title deletes it
An empty answer must be treated as keep, so the title is only changed when new_title != "".
The report or export file is empty
You opened the file for writing and wrote nothing, or wrote to it after the with block ended. All the
file.write() calls must be indented inside the with.
Exercises¶
- Search. Add an option that asks for a word and prints every task whose title contains it, ignoring case,
with its due date and its
Whenphrase, ordone. - Reopen. Add an option that marks a done task as not done after all, and refuses politely if the chosen task is not done.
- Report. Add an option that writes the open tasks to
report.txtas readable text: a heading with today's date in words, then one line per task with the due date as21 September, theWhenphrase and the priority. Tell the user how many tasks were written. - Categories. Give every task a category, such as
homeorwork, defaulting togeneral. This needs the file format to change, so start intodo_storage.pyand run its self-test before touchingtodo.py. Then ask for the category when adding, and show it in the table. Think about what happens to the tasks already saved in the old format, and decide what to do about them.
Solution 1
# A new function for todo.py, plus a menu option that calls it. Nothing to save.
def search_tasks(tasks, today):
"""Ask for a word and print every task whose title contains it, ignoring case."""
word = input("Search for: ").strip().lower()
found = 0
for task in tasks:
if word in task["title"].lower():
if task["done"]:
when = "done"
else:
when = describe_due(task, today)
print(f"{task['title']} ({task['due']}, {when})")
found += 1
if found == 0:
print(f"No tasks contain '{word}'.")
Both the search word and the title are lower-cased before the in check, the same trick as in Module 2.
Solution 2
# A new function for todo.py, plus a menu option that calls it and saves.
def reopen_task(tasks, today):
"""Ask which done task to mark as not done after all."""
if len(tasks) == 0:
print("Nothing to reopen!")
return
show_tasks(tasks, today)
index = choose_task(tasks, "Which number should be reopened? ")
if index is None:
return
if not tasks[index]["done"]:
print(f"'{tasks[index]['title']}' is not done yet.")
return
tasks[index]["done"] = False
print(f"Reopened: {tasks[index]['title']}")
It is finish_task() with the boolean set the other way, plus one guard for a task that is not done.
Solution 3
# A new function for todo.py, plus a menu option that calls it. Nothing to save to tasks.txt.
def write_report(tasks, today):
"""Write the open tasks to report.txt as readable text, and say how many were written."""
written = 0
with open("report.txt", "w") as file:
file.write(f"To do as of {today.strftime('%A %d %B %Y')}\n")
file.write("\n")
for task in tasks:
if not task["done"]:
due = task["due"].strftime("%d %B")
file.write(f"- {task['title']} (due {due}, {describe_due(task, today)}, priority {task['priority']})\n")
written += 1
print(f"Wrote {written} tasks to report.txt.")
strftime() from Module 8 produces the friendly dates. The report is written with "w", so each run replaces
the last report, which is what you want for a snapshot.
Solution 4
# Adding a category to every task touches four places. Here are the changed functions.
# ---- todo_storage.py: the file format gains a fifth part ----
def task_to_line(task):
"""Return a task as one line of text: title|YYYY-MM-DD|priority|yes or no|category."""
if task["done"]:
done = "yes"
else:
done = "no"
return f"{task['title']}|{task['due']}|{task['priority']}|{done}|{task['category']}"
def line_to_task(line):
"""Return the task described by one line of the file. A malformed line causes a ValueError."""
title, due_text, priority_text, done_text, category = line.split("|")
return {
"title": title,
"due": date.fromisoformat(due_text),
"priority": int(priority_text),
"done": done_text == "yes",
"category": category,
}
# ---- todo.py: ask for it when adding, and show it in the table ----
def add_task(tasks):
"""Ask for a title, due date, priority and category, and add the new task to the list."""
title = ask_for_title("Title: ")
due = ask_for_date("Due (YYYY-MM-DD): ")
priority = ask_for_priority("Priority, 1 (urgent) to 3 (whenever): ")
category = input("Category (Enter for 'general'): ").strip().lower()
if category == "" or "|" in category:
category = "general"
tasks.append({"title": title, "due": due, "priority": priority, "done": False, "category": category})
tasks.sort(key=due_date)
print(f"Added: {title}")
def show_tasks(tasks, today):
"""Print the tasks as a table, with the category and a column saying when each one is due."""
if len(tasks) == 0:
print("Nothing to do!")
return
print(f"{'#':>2} {'':<4}{'Title':<28}{'Category':<10}{'Due':<12}{'Pri':>3} {'When'}")
number = 1
for task in tasks:
if task["done"]:
mark = "[x]"
else:
mark = "[ ]"
print(f"{number:>2} {mark:<4}{task['title']:<28}{task['category']:<10}{str(task['due']):<12}{task['priority']:>3} {describe_due(task, today)}")
number += 1
# Old lines in tasks.txt have four parts, so line_to_task() would skip them all. Before running the
# new version, add |general to the end of every line in the file, once.
Four functions change and nothing else does, because the file format lives in two functions and the task's shape
is only built in add_task(). The old save file has four parts per line, so the new line_to_task() would skip
every line of it. The simplest fix is to add |general to the end of each line by hand, once. A more careful
line_to_task() could accept both four and five parts, and that is a good stretch goal.
Summary¶
- Small courtesies make a program pleasant to use: say tomorrow, offer to keep the current value, open with a summary.
- When two functions need the same piece, give the piece a name.
ask_for_title()andask_for_priority()came out ofadd_task()whenedit_task()needed them. - To change a dictionary inside a list, hold it in a variable and change its keys. The list sees the change.
- The finished app uses something from every module of the course, and nothing beyond them.
Module 9 complete¶
You have planned, built, tested and polished a real program, in three files, that saves its data and survives bad input. That is further than most people who start learning to program ever get. Module 10 introduces one more idea, classes, which give a name and a home to things like the task dictionary, and then Module 11 sends you on your way.