Lesson 11: Capstone — Build a Study Planner App
🎧 Listen to this lesson
A capstone is where separate skills become a program. In this lesson you will design and build a study planner: it stores tasks with subjects and due dates, lists them sorted by urgency, marks them complete, and — using Lessons 6 and 10's persistence pattern — saves everything to a JSON file so your data survives between runs. The code is not the hard part; the architecture is. Professionals plan the pieces before writing any of them, and this lesson shows you that planning explicitly.
Design before code
Write the plan down first. For the planner, four decisions cover everything:
| Piece | Decision |
|---|---|
| Data shape | Each task is a dict: subject, title, due (ISO string "2026-10-25"), done (bool) |
| Storage | One JSON file, planner.json; load on start, save after every change |
| Functions | load_tasks, save_tasks, add_task, list_tasks, complete_task, plus a main menu loop |
| Interface | A numbered text menu; the user picks 1-4 |
Two of these decisions carry the whole design. The fixed schema — the same four keys in every task dictionary, created in exactly one function (add_task) — is what keeps hundreds of tasks processable; inconsistent data (a task missing "due", another calling it "deadline") is the number-one headache in real data work, and it is prevented here by never building a task dict anywhere else. And save after every change means a crash, a closed laptop or a dead battery loses at most nothing: the file on disk is always current. ISO dates (YYYY-MM-DD) earn their keep too — because they are zero-padded and ordered biggest-unit-first, plain string comparison sorts them chronologically. Watch that fact work:
Worked example 1 — sorting and listing tasks
Before any files or menus, prove the core view works on hard-coded data:
def by_due(tasks):
"""Return the tasks sorted by due date (ISO strings sort correctly)."""
return sorted(tasks, key=lambda t: t["due"])
def show(tasks):
for t in by_due(tasks):
mark = "x" if t["done"] else " "
print(f"[{mark}] {t['due']} {t['subject']}: {t['title']}")
tasks = [
{"subject": "Math", "title": "Algebra homework", "due": "2026-11-02", "done": False},
{"subject": "Art", "title": "Sketch a still life", "due": "2026-10-30", "done": False},
{"subject": "Science", "title": "Lab report", "due": "2026-10-28", "done": True},
]
show(tasks)
Output:
[x] 2026-10-28 Science: Lab report
[ ] 2026-10-30 Art: Sketch a still life
[ ] 2026-11-02 Math: Algebra homework
The sort key lambda t: t["due"] ranks tasks by their date string, and because the format is ISO the string ranking *is* the chronological ranking — try that trick with "30/10/2026" formats and it falls apart, which is why the schema decision mattered. The checkbox mark comes from a conditional expression, and done tasks still show (with an x) because reviewing what you finished is motivating and sometimes graded.
Worked example 2 — finding overdue tasks
The planner's most valuable view answers "what am I late on?" A task is overdue if it is not done and its due date is before today — and to keep the logic testable, today arrives as a parameter rather than being read from the clock inside the function:
def overdue_tasks(tasks, today):
"""Return unfinished tasks due before today (ISO date strings)."""
return [t for t in tasks if not t["done"] and t["due"] < today]
tasks = [
{"subject": "Math", "title": "Algebra homework", "due": "2026-10-25", "done": False},
{"subject": "History", "title": "Essay draft", "due": "2026-10-20", "done": False},
{"subject": "Science", "title": "Lab report", "due": "2026-10-19", "done": True},
]
late = overdue_tasks(tasks, "2026-10-22")
for t in late:
print(f"OVERDUE: {t['subject']}: {t['title']} (due {t['due']})")
print("Overdue count:", len(late))
Output:
OVERDUE: History: Essay draft (due 2026-10-20)
Overdue count: 1
Only the History essay qualifies: the Math task is due in the future, and the Science report — although its date is the earliest — is done, and the not t["done"] half of the condition excludes it. Passing today in makes this function trivially testable (Lesson 17): the same code can be checked against any date without waiting for the calendar. In the real app you would pass str(date.today()) from the datetime module.
The complete planner
Assemble the pieces: persistence from Lesson 6, the schema and views from the worked examples, and a menu loop that validates its input:
import json
SAVE_FILE = "planner.json"
def load_tasks():
"""Load tasks from disk; start empty if the file is missing or broken."""
try:
with open(SAVE_FILE, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return []
def save_tasks(tasks):
"""Write the task list to disk. Called after every change."""
with open(SAVE_FILE, "w", encoding="utf-8") as f:
json.dump(tasks, f, indent=2)
def add_task(tasks, subject, title, due):
tasks.append({"subject": subject, "title": title, "due": due, "done": False})
save_tasks(tasks)
print(f"Added: {title}")
def list_tasks(tasks):
if not tasks:
print("No tasks yet. Add one!")
return
for i, t in enumerate(tasks, 1):
status = "DONE" if t["done"] else "todo"
print(f"{i}. [{status}] {t['subject']}: {t['title']} (due {t['due']})")
def complete_task(tasks, number):
if 1 <= number <= len(tasks):
tasks[number - 1]["done"] = True
save_tasks(tasks)
print("Marked complete. Nice work!")
else:
print("No task with that number.")
def main():
tasks = load_tasks()
print(f"Loaded {len(tasks)} saved task(s).")
while True:
print("\n1) Add 2) List 3) Complete 4) Quit")
choice = input("> ").strip()
if choice == "1":
add_task(tasks, input("Subject: "), input("Title: "), input("Due date: "))
elif choice == "2":
list_tasks(tasks)
elif choice == "3":
list_tasks(tasks)
try:
complete_task(tasks, int(input("Task number: ")))
except ValueError:
print("Please enter a number.")
elif choice == "4":
print("Tasks saved. See you next study session!")
break
else:
print("Pick 1-4.")
if __name__ == "__main__":
main()
Sample run:
Loaded 0 saved task(s).
1) Add 2) List 3) Complete 4) Quit
> 2
No tasks yet. Add one!
1) Add 2) List 3) Complete 4) Quit
> 1
Subject: Math
Title: Algebra homework
Due date: 2026-10-25
Added: Algebra homework
1) Add 2) List 3) Complete 4) Quit
> 1
Subject: Science
Title: Lab report
Due date: 2026-10-24
Added: Lab report
1) Add 2) List 3) Complete 4) Quit
> 2
1. [todo] Math: Algebra homework (due 2026-10-25)
2. [todo] Science: Lab report (due 2026-10-24)
1) Add 2) List 3) Complete 4) Quit
> 3
1. [todo] Math: Algebra homework (due 2026-10-25)
2. [todo] Science: Lab report (due 2026-10-24)
Task number: 2
Marked complete. Nice work!
1) Add 2) List 3) Complete 4) Quit
> 4
Tasks saved. See you next study session!
Run it twice and the second launch reports Loaded 2 saved task(s). — the persistence works. Open planner.json in your editor and read it: indent=2 makes it human-readable, and seeing your own data as JSON demystifies every save file you will ever meet. Then delete the file while the program is not running and relaunch — it starts fresh without crashing, because load_tasks planned for exactly that. Notice, too, how each menu option maps to one function, so each can be tested alone; and the else branch catches menu typos instead of trusting the user to type carefully. Defensive design like this is the difference between software that feels solid and software that feels fragile.
Common mistakes
- Saving only when the user quits politely. Close the laptop mid-session and the data is gone. Save inside
add_taskandcomplete_task— after every mutation, not at the end. - Building task dictionaries in several places. One day a key gets mistyped and one task in fifty crashes the listing with KeyError. One constructor spot (
add_task) keeps the schema airtight. - Trusting menu input.
int(input("Task number: "))on the text "two" raises ValueError and kills the app. It is wrapped in try/except here, andcomplete_taskrange-checks the number — two layers, because users are creative. - Non-ISO date strings.
"2 Nov 2026"does not sort, compare or filter chronologically as a string. Store ISO, format prettily only at display time if you must.
Real-World Challenge
The task. Build a habit tracker on the planner's architecture — same persistence pattern, simpler data. Habits map to a count of days logged, stored as a JSON dictionary. Logging a day increments the count and saves immediately; a report lists habits ranked by days.
Worked solution.
import json
SAVE_FILE = "habits.json"
def load_habits():
"""Load the habit counts; start empty on first run or a bad file."""
try:
with open(SAVE_FILE, encoding="utf-8") as f:
return json.load(f)
except (FileNotFoundError, json.JSONDecodeError):
return {}
def save_habits(habits):
with open(SAVE_FILE, "w", encoding="utf-8") as f:
json.dump(habits, f, indent=2)
def log_day(habits, name):
habits[name] = habits.get(name, 0) + 1
save_habits(habits)
print(f"{name}: {habits[name]} day(s) logged")
def report(habits):
for name in sorted(habits, key=habits.get, reverse=True):
print(f"{name}: {habits[name]}")
habits = load_habits()
log_day(habits, "Reading")
log_day(habits, "Reading")
log_day(habits, "Piano practice")
report(habits)
Output:
Reading: 1 day(s) logged
Reading: 2 day(s) logged
Piano practice: 1 day(s) logged
Reading: 2
Piano practice: 1
How it works. Every planner decision transfers: JSON persistence with the load-or-fresh pattern, save after every change, one function per action. The counting is Lesson 5's pattern — habits.get(name, 0) + 1 — and the report ranks with sorted(habits, key=habits.get, reverse=True), the max-by-value trick from the word-frequency example generalised to a full ranking. The demo at the bottom plays the role of the menu for this compact version: three log calls and a report. Swap the demo for a loop with input() and you have the complete app; the functions would not change at all, which is precisely the sign that the architecture is right.
Extension. Days logged is a weak streak measure — it never resets. Store a list of ISO date strings per habit instead of a count, and compute the current streak: consecutive days ending today. The schema changes (list replaces int), the persistence and menu survive untouched — a realistic taste of how requirements evolve under a stable architecture.
Going further — from lists of tasks to today's priorities
The planner stores everything; users live in today. A priority view is one more filter over the same schema — unfinished tasks due today or earlier, the "needs attention" list a real planner shows on launch:
def urgent(tasks, today):
"""Unfinished tasks due today or earlier."""
return [t for t in tasks if not t["done"] and t["due"] <= today]
tasks = [
{"subject": "Math", "title": "Algebra", "due": "2026-10-22", "done": False},
{"subject": "Art", "title": "Sketch", "due": "2026-10-30", "done": False},
]
print([t["title"] for t in urgent(tasks, "2026-10-22")])
Output: ['Algebra'] — the Math task is due today and unfinished; the Art task is next week and correctly absent. Compare this function with Worked example 2's overdue detector: they differ by a single character's worth of intent (< today versus <= today) and answer different questions — "what am I late on?" versus "what needs me now?". Small, single-purpose selectors like this are how applications grow features without growing fragile: each is independently testable against hand-built data, none touches storage or the menu, and the main program composes them at display time. If you take one architectural instinct from this capstone, take that one — new questions about existing data should cost one small function, never a rewrite.
🎬 Watch & Learn
Prefer video? These searches surface top tutorials on this lesson's topic:
- Build a Python app start to finish — project tutorial (Tech With Tim style) — pick a recent, well-reviewed video
- freeCodeCamp: Python project — planner/todo app — pick a recent, well-reviewed video
- How to plan a coding project before you code — pick a recent, well-reviewed video
💪 Practice Zone
Try these yourself — hints are included, answers are not. Run your code in any Python environment.
- Add priorities — Extend the planner so every task has a priority (
high/medium/low). List high-priority tasks first.Hint
Add a"priority"key to each task dict; sort withsorted(tasks, key=lambda t: ...)or filter in the list function. - Sort by due date — Make the task list display in due-date order instead of entry order.
Hint
Store due dates as"2026-10-20"(year-month-day) so plain string sorting works. - This-week view — Add a menu option that shows only tasks due within the next 7 days.
Hint
Usedatetime.date.today()anddatetime.timedelta(days=7); compare withdate.fromisoformat(task["due"]).
🌍 Real World
Apps like Todoist and Google Tasks are polished versions of your study planner: a data model, a list view, persistence, and reminders. Productivity software is a multi-billion-dollar industry built on exactly these pieces — your capstone is a real product category.