Skip to content

Planning the app

Goal: decide what the to-do app will do, how its data will be stored, and which functions it needs, before writing any code.

Why this matters

Module 9 is one project spread over several lessons: a to-do app with due dates, priorities and a save file, built the way you would build it for real. Every earlier project fitted in one lesson, so you could hold the whole thing in your head. This one does not, and that changes how you work. Programmers who start a project like this by typing code usually end up rewriting it. Programmers who start with a plan usually do not. This lesson is the plan. There is still code in it, because a good plan is tested before it is trusted.

What the app will do

The starting point is the split to-do app from the last exercise of Module 8: a main program, a storage module and helpers.py. It stores a title per task and nothing else. The finished app will let the user:

  1. Add a task with a title, a due date and a priority from 1 (urgent) to 3 (whenever).
  2. See all tasks in a table, sorted by due date, with done tasks and overdue tasks marked.
  3. Mark a task as done.
  4. Delete a task, after confirming.
  5. Have everything saved automatically after every change.

Just as important is what it will not do. No editing a task once added. No categories, no reminders, no colours. Each of those is a reasonable idea, and the fastest way to never finish a project is to say yes to every reasonable idea. Write the extras down for later and build the five things on the list.

Describing one task

The old app stored a task as a string. A task now has four facts about it, so it needs a value that can hold four facts under names. That is a dictionary, from Module 4:

task_example.py
from datetime import date

task = {
    "title": "Buy milk",
    "due": date(2026, 9, 21),
    "priority": 2,
    "done": False,
}

print(task["title"])
print(task["due"])
print(task["priority"] == 1)
print(task)
Buy milk
2026-09-21
False
{'title': 'Buy milk', 'due': datetime.date(2026, 9, 21), 'priority': 2, 'done': False}

Every task will have exactly these four keys. The values are of different types, a string, a date, an integer and a boolean, and a dictionary is happy to hold a mixture. task["due"] is a real date from Module 8, not text, so the app can compare and sort with it. task["done"] is a boolean, so the app can write if task["done"]:.

The last line shows what happens when you print the whole dictionary. Python shows the date as datetime.date(2026, 9, 21), which is how it would be written in code. That is fine for checking your work, and the user will never see it, because the app will print tasks its own way.

A list of tasks

The app works on many tasks, so the tasks live in a list, and each item in the list is one of these dictionaries:

tasks_example.py
from datetime import date

tasks = [
    {"title": "Buy milk", "due": date(2026, 9, 21), "priority": 2, "done": False},
    {"title": "Finish Module 9", "due": date(2026, 10, 3), "priority": 1, "done": False},
    {"title": "Call the dentist", "due": date(2026, 9, 18), "priority": 3, "done": True},
]

for task in tasks:
    print(f"{task['title']} is due on {task['due']}")

print(f"{len(tasks)} tasks, first one: {tasks[0]['title']}")
Buy milk is due on 2026-09-21
Finish Module 9 is due on 2026-10-03
Call the dentist is due on 2026-09-18
3 tasks, first one: Buy milk

A list of dictionaries is new, but nothing in it is. The loop hands you one dictionary at a time, and inside the loop you look up keys as usual. tasks[0]['title'] reads from left to right: the first item of the list, then its title. Notice the quotes: inside an f-string written with double quotes, the key uses single quotes, as with strftime in Module 8.

Everything the app does is a change to this list. Adding a task appends a dictionary. Finishing one sets its "done" to True. Deleting one removes it with .pop(). Saving writes the list to a file, and loading builds the list from the file. That is the entire design of the program, in one paragraph.

Sorting the tasks

Feature 2 says the table is sorted by due date. Module 4 gave you .sort(), which sorts a list of numbers or strings. A list of dictionaries needs two more things: a way to say sort by this part, and a sort that does not rearrange the original list, because the user's task numbers must not change every time they look. sorted() does both:

sort_by_due.py
from datetime import date

tasks = [
    {"title": "Buy milk", "due": date(2026, 9, 21), "priority": 2, "done": False},
    {"title": "Finish Module 9", "due": date(2026, 10, 3), "priority": 1, "done": False},
    {"title": "Call the dentist", "due": date(2026, 9, 18), "priority": 3, "done": True},
]

def due_date(task):
    """Return the part of a task to sort by."""
    return task["due"]

for task in sorted(tasks, key=due_date):
    print(f"{task['due']}  {task['title']}")

print()
print(sorted([3, 1, 2]))
print(sorted(["pear", "apple", "fig"]))
2026-09-18  Call the dentist
2026-09-21  Buy milk
2026-10-03  Finish Module 9

[1, 2, 3]
['apple', 'fig', 'pear']

sorted() is a function, not a method. Give it any list and it returns a new sorted list, leaving the original alone. The last two lines show it on plain values.

The key= part is the new idea. key=due_date tells sorted() to sort the tasks by whatever due_date() returns for each one. Look carefully at how the function is passed: due_date, without parentheses. Writing due_date(task) would call the function and hand over one date. Writing due_date hands over the function itself, and sorted() calls it, once per task, to find out what to sort by. Functions are values in Python, and they can be passed around like any other value. This is the first time the course has done it, and it will not be the last.

key= is a keyword argument, the kind you met with timedelta(days=7). You cannot leave the name out.

Lining up the table

Feature 2 also says a table. Printed with plain f-strings, columns wander as titles change length. An f-string can pad a value to a fixed width:

table.py
from datetime import date

tasks = [
    {"title": "Buy milk", "due": date(2026, 9, 21), "priority": 2, "done": False},
    {"title": "Finish Module 9", "due": date(2026, 10, 3), "priority": 1, "done": False},
    {"title": "Call the dentist", "due": date(2026, 9, 18), "priority": 3, "done": True},
]

print(f"{'#':>2}  {'Title':<20}{'Due':<12}{'Pri':>3}")
number = 1
for task in tasks:
    print(f"{number:>2}  {task['title']:<20}{str(task['due']):<12}{task['priority']:>3}")
    number += 1
 #  Title               Due         Pri
 1  Buy milk            2026-09-21    2
 2  Finish Module 9     2026-10-03    1
 3  Call the dentist    2026-09-18    3

After a value inside the braces, a colon starts a format specification. :<20 means make this 20 characters wide, lined up on the left, padding with spaces. :>3 means 3 wide, lined up on the right, which is what you want for numbers. The header row uses the same widths on the column names, so everything lines up.

The date has str() around it for a reason. A date given a format specification treats it as a strftime template, so {task['due']:<12} prints the literal text <12. Turning it into a string first makes it behave like any other text.

The save file

The tasks must survive between runs, so the app needs a file format. The old app used one task per line, and that still works, with the four parts of a task separated by a character that will never appear in a title:

tasks_sample.txt
Buy milk|2026-09-21|2|no
Finish Module 9|2026-10-03|1|no
Call the dentist|2026-09-18|3|yes
Book flights, hotel and car|2026-11-01|1|no

Three decisions are hiding in that file, and each one is worth stating:

  • The separator is |, not a comma. The last task's title contains commas. Splitting on commas would break it into pieces. Nobody puts a | in a to-do item.
  • Dates are in ISO form, which is what an f-string gives you and what date.fromisoformat() reads back, so the round trip loses nothing.
  • Done is yes or no, not True and False. When the file is read, done_text == "yes" turns the word back into a boolean in one step.

Each line is task_to_line() in one direction and line_to_task() in the other. A line that does not fit the pattern, because someone edited the file by hand, will cause a ValueError somewhere in line_to_task(), in fromisoformat() or int() or the split, and the loader will catch it and skip the line, as in Module 7.

The plan itself

Here is the plan, written as Python. Every function the app needs, with a docstring saying its one job, and no code:

plan.py
"""The plan for the to-do app: every function, with its job, and no code yet.

Files:
    todo_storage.py  - turning tasks into lines and lines into tasks, loading, saving
    todo.py          - the menu and everything the user sees
    helpers.py       - ask_for_number, ask_for_date, ask_yes_no, read_lines (from Module 8)
"""

# ---- todo_storage.py ----

def task_to_line(task):
    """Return a task as one line of text: title|YYYY-MM-DD|priority|yes or no."""

def line_to_task(line):
    """Return the task described by one line of the file. A malformed line causes a ValueError."""

def load_tasks():
    """Return the saved tasks as a list, skipping bad lines. Empty list if there is no file."""

def save_tasks(tasks):
    """Write every task to the file, replacing what was there."""

# ---- todo.py ----

def show_tasks(tasks, today):
    """Print the tasks as a table, sorted by due date, marking done and overdue ones."""

def add_task(tasks):
    """Ask for a title, due date and priority, and add the new task to the list."""

def finish_task(tasks):
    """Ask which task is done and mark it done."""

def delete_task(tasks):
    """Ask which task to delete, confirm, and remove it."""

def show_menu():
    """Print the menu and return the user's choice."""

def main():
    """Load the tasks, run the menu until the user quits, saving after every change."""

A function whose body is only a docstring is valid Python, and it does nothing and returns None. Run the file and nothing happens, which is correct. Ask for help(plan), though, and you get a readable description of the whole app, generated from the docstrings, exactly as with helpers.py in Module 8.

Writing the docstrings before the code is planning. It forces you to decide what each function takes and what it returns, while changing your mind is still free. A few of the decisions in this file:

  • load_tasks() and save_tasks() are the same jobs as in Module 8, but they now use line_to_task() and task_to_line(), so the file format lives in exactly two functions. Change the format later and nothing else moves.
  • show_tasks() takes today as a parameter rather than asking for the date itself, so that it can be tested with any date, as describe() in the countdown was.
  • main() is a function too. The Module 8 app had its menu loop at the top level, and Module 8 showed why that is a problem: importing the file would run the app. With main() and the if __name__ == "__main__": line, todo.py can be imported to test a function without the menu starting up.
  • helpers.py is listed but not planned, because it is finished. ask_yes_no(), from the Module 8 exercises, is what delete_task() will use to confirm.

The next lesson builds todo_storage.py and tests it on its own. The one after builds todo.py on top of it.

Try it

Look at the five features and the plan, and find the function each feature will need. Then add one feature of your own to the not now list, and write down, in one sentence each, which functions it would need and how the file format would have to change. You do not have to build it, but you should know what it would cost.

Common mistakes

KeyError: 'tittle'

A misspelled key. Every task has exactly the keys title, due, priority and done, all lower case. The message shows the key you asked for, so compare it letter by letter with those four.

TypeError: '<' not supported between instances of 'dict' and 'dict'

sorted(tasks) without key=. Python does not know how to compare two dictionaries. Tell it what to compare with key=due_date.

TypeError: due_date() missing 1 required positional argument: 'task'

You wrote key=due_date() with parentheses, which calls the function on the spot. Pass the function itself: key=due_date.

TypeError: '<' not supported between instances of 'str' and 'datetime.date'

Some tasks have a real date under "due" and some have text. Usually one was made with date.fromisoformat() and the other was not. Convert at the moment the text arrives, in line_to_task() or ask_for_date(), so that every task in the list looks the same.

AttributeError: 'list' object has no attribute 'sorted'

sorted() is a function that takes the list, not a method on the list: sorted(tasks, key=due_date), not tasks.sorted(). The method is .sort(), which sorts in place and returns None.

The date column shows <12 instead of a date

A date given a format specification treats it as a strftime template. Wrap it: {str(task['due']):<12}.

Exercises

  1. Sort by priority. Make a list of three task dictionaries and print them sorted by priority, most urgent first.
  2. Task table. Print the same three tasks as a table with aligned columns, plus a column showing [x] for done tasks and [ ] for the rest.
  3. There and back. Write task_to_line() and line_to_task() following the docstrings in the plan and the file format above. Turn a task into a line, print it, turn the line back into a task, and check with == that you got the original task back. These two functions are the first real code of the app, and you will use them next lesson.
  4. Plan another app. Write a plan.py for a contacts app that stores a name, phone number, email and birthday for each person, and can list contacts, search by name, delete one, and show upcoming birthdays. Decide the shape of one contact and the file format, and write them in the module docstring. Docstrings only, no code.
Solution 1
from datetime import date

tasks = [
    {"title": "Water the plants", "due": date(2026, 9, 20), "priority": 3, "done": False},
    {"title": "Pay the rent", "due": date(2026, 10, 1), "priority": 1, "done": False},
    {"title": "Read a chapter", "due": date(2026, 9, 25), "priority": 2, "done": False},
]

def priority(task):
    """Return the part of a task to sort by."""
    return task["priority"]

for task in sorted(tasks, key=priority):
    print(f"Priority {task['priority']}: {task['title']}")

The key function returns the priority instead of the date. Everything else is the same as sort_by_due.py.

Solution 2
from datetime import date

tasks = [
    {"title": "Water the plants", "due": date(2026, 9, 20), "priority": 3, "done": True},
    {"title": "Pay the rent", "due": date(2026, 10, 1), "priority": 1, "done": False},
    {"title": "Read a chapter", "due": date(2026, 9, 25), "priority": 2, "done": False},
]

print(f"{'#':>2}  {'':<4}{'Title':<20}{'Due':<12}{'Pri':>3}")
number = 1
for task in tasks:
    if task["done"]:
        mark = "[x]"
    else:
        mark = "[ ]"
    print(f"{number:>2}  {mark:<4}{task['title']:<20}{str(task['due']):<12}{task['priority']:>3}")
    number += 1

The mark is chosen with an if before the print, which keeps the f-string readable. The header has an empty column, {'':<4}, so that the titles line up under their heading.

Solution 3
from datetime import date

def task_to_line(task):
    """Return a task as one line of text: title|YYYY-MM-DD|priority|yes or no."""
    if task["done"]:
        done = "yes"
    else:
        done = "no"
    return f"{task['title']}|{task['due']}|{task['priority']}|{done}"

def line_to_task(line):
    """Return the task described by one line of the file."""
    title, due_text, priority_text, done_text = line.split("|")
    return {
        "title": title,
        "due": date.fromisoformat(due_text),
        "priority": int(priority_text),
        "done": done_text == "yes",
    }

task = {"title": "Buy milk", "due": date(2026, 9, 21), "priority": 2, "done": False}

line = task_to_line(task)
print(line)

back = line_to_task(line)
print(back)
print(back == task)

back == task is True: dictionaries compare equal when they have the same keys and values, and the date came back as a real date. line_to_task() unpacks the four parts of the split in one line, the way name, score was unpacked in Module 6. A line with the wrong number of parts causes a ValueError there, which is what the plan wants.

Solution 4
"""The plan for a contacts app.

A contact is a dictionary: {"name": str, "phone": str, "email": str, "birthday": date}.
The file has one contact per line: name|phone|email|YYYY-MM-DD.

Files:
    contacts_storage.py - contact_to_line, line_to_contact, load_contacts, save_contacts
    contacts.py         - the menu and everything the user sees
    helpers.py          - ask_for_date, ask_yes_no, read_lines
"""

# ---- contacts_storage.py ----

def contact_to_line(contact):
    """Return a contact as one line of text: name|phone|email|YYYY-MM-DD."""

def line_to_contact(line):
    """Return the contact described by one line of the file. A malformed line causes a ValueError."""

def load_contacts():
    """Return the saved contacts as a list, skipping bad lines. Empty list if there is no file."""

def save_contacts(contacts):
    """Write every contact to the file, replacing what was there."""

# ---- contacts.py ----

def show_contacts(contacts):
    """Print the contacts as a table, sorted by name."""

def add_contact(contacts):
    """Ask for the details of a new contact and add it to the list."""

def find_contact(contacts):
    """Ask for part of a name and print every contact that matches."""

def delete_contact(contacts):
    """Ask which contact to delete, confirm, and remove it."""

def show_birthdays(contacts, today):
    """Print the contacts whose birthday is in the next 30 days."""

def main():
    """Load the contacts, run the menu until the user quits, saving after every change."""

Your plan will differ, and that is fine. What matters is that every function has one job, that the storage functions are separate from the menu functions, and that the file format is written down before any code exists. Notice how much of it is the to-do plan with the nouns changed. Most programs that keep records look like this.

Summary

  • A task is a dictionary with four keys: title, due, priority and done. The app's data is a list of them.
  • sorted(items, key=function) returns a new sorted list, ordered by what function returns for each item. Pass the function without parentheses.
  • In an f-string, :<20 pads a value to 20 characters on the left and :>3 lines it up on the right. Wrap dates in str().
  • The file has one task per line, parts separated by |, dates in ISO form, and yes or no for done.
  • Plan with docstrings: every function, its one job, no code. Decide what the app will not do.

Next: The storage module