Capstone: Build Something

The Course
•
January 11, 2026
•
6 min read

Six days of pieces. Today they become a program.

The project below is a habit tracker: it stores entries, saves them to a file, and reports on them. It is small enough to read in one sitting and real enough to be worth finishing.

The finished program

Read it first, then run it. Every construct in here comes from a lesson this week — dataclasses from Day 6, comprehensions from Day 4, json and exception handling from Day 5, dictionaries from Day 3.

Habit tracker Python
"""A small habit tracker: record, save, and report.""" from storage import load, save from tracker import Tracker def main(): tracker = Tracker(load()) tracker.record("read", "2026-01-05", 30) tracker.record("read", "2026-01-06", 45) tracker.record("walk", "2026-01-06", 20) tracker.record("read", "2026-01-07", 25) save(tracker.entries) print(tracker.report()) print() print(f"Longest streak for 'read': {tracker.streak('read')} days") if __name__ == "__main__": main() """The domain model - no printing, no files, just the rules.""" from dataclasses import dataclass from datetime import date @dataclass(frozen=True) class Entry: habit: str day: str minutes: int def as_dict(self): return {"habit": self.habit, "day": self.day, "minutes": self.minutes} class Tracker: def __init__(self, entries=None): self.entries = list(entries or []) def record(self, habit, day, minutes): """Add an entry, rejecting nonsense.""" if minutes <= 0: raise ValueError(f"minutes must be positive, got {minutes}") self.entries.append(Entry(habit, day, minutes)) def habits(self): return sorted({entry.habit for entry in self.entries}) def total_minutes(self, habit): return sum(e.minutes for e in self.entries if e.habit == habit) def days_for(self, habit): return sorted({e.day for e in self.entries if e.habit == habit}) def streak(self, habit): """Longest run of consecutive calendar days.""" days = [date.fromisoformat(d) for d in self.days_for(habit)] if not days: return 0 longest = current = 1 for previous, day in zip(days, days[1:]): if (day - previous).days == 1: current += 1 longest = max(longest, current) else: current = 1 return longest def report(self): lines = ["Habit Days Minutes", "-" * 24] for habit in self.habits(): days = len(self.days_for(habit)) lines.append(f"{habit:10} {days:4} {self.total_minutes(habit):7}") return "\n".join(lines) """Everything that touches the disk lives here.""" import json from pathlib import Path from tracker import Entry PATH = Path("habits.json") def load(): """Return saved entries, or an empty list if there are none.""" try: raw = json.loads(PATH.read_text()) except FileNotFoundError: return [] except json.JSONDecodeError: print(" (habits.json is corrupt - starting fresh)") return [] return [Entry(**item) for item in raw] def save(entries): PATH.write_text(json.dumps([e.as_dict() for e in entries], indent=2))

Why it is split this way

Three modules, three jobs, dependencies pointing one way:

  • tracker.py holds the rules and knows nothing about files or printing. Every method is testable with no setup at all.
  • storage.py is the only module that touches the filesystem. Swap it for a database module and nothing in tracker.py changes.
  • main.py decides what actually happens, behind a main guard.

That separation — rules, storage, entry point — is the smallest useful version of how most real applications are laid out.

One line in storage.py uses something from an explore lesson: Entry(**item). The ** spreads a dictionary into keyword arguments, so {"habit": "read", "day": "2026-01-05", "minutes": 30} becomes Entry(habit="read", day="2026-01-05", minutes=30). Arguments in Depth has the rest of the story; that sentence is all this program needs.

Your turn

Take the program above and make it yours. In rough order of difficulty:

  1. Add average_minutes(habit) and show it in the report.
  2. Add record validation: reject a day that is not YYYY-MM-DD. date.fromisoformat raises ValueError for you — catch and re-raise with a clearer message.
  3. Add a remove(habit, day) method, and decide what should happen when there is no such entry.
  4. Write tests for streak — the empty case, a single day, a broken run, and two separate runs where the longer one comes first.
  5. Add a command line: read commands from input() in a loop — add read 2026-01-08 30, report, quit — parsing with split() and handling bad commands without crashing.
Exercise

Start here: average minutes and a tested streak

Add average_minutes(habit) to Tracker and print it from main.py, then write assertions covering the four streak cases listed above. For the entries already in main.py, the average for "read" is 33.3, and when all four streak assertions pass, nothing complains.

from tracker import Tracker tracker = Tracker() tracker.record("read", "2026-01-05", 30) tracker.record("read", "2026-01-06", 45) tracker.record("read", "2026-01-09", 25) # 1. Add average_minutes to tracker.py, then print it here. # 2. Write your streak assertions here. from dataclasses import dataclass from datetime import date @dataclass(frozen=True) class Entry: habit: str day: str minutes: int class Tracker: def __init__(self, entries=None): self.entries = list(entries or []) def record(self, habit, day, minutes): if minutes <= 0: raise ValueError(f"minutes must be positive, got {minutes}") self.entries.append(Entry(habit, day, minutes)) def days_for(self, habit): return sorted({e.day for e in self.entries if e.habit == habit}) def total_minutes(self, habit): return sum(e.minutes for e in self.entries if e.habit == habit) def streak(self, habit): days = [date.fromisoformat(d) for d in self.days_for(habit)] if not days: return 0 longest = current = 1 for previous, day in zip(days, days[1:]): if (day - previous).days == 1: current += 1 longest = max(longest, current) else: current = 1 return longest # Your average_minutes here
Hint 1: the idea

average_minutes needs no new data: total_minutes(habit) and days_for(habit) already give you the two numbers an average is made of. The only new decision is what the average of no days should be. Each streak test is a fresh Tracker holding just the entries that make its case, and one assert about its streak.

Hint 2: a first step

Get print(tracker.average_minutes("read")) showing 33.3 first — the entries in main.py total 100 minutes over 3 days. Then write the easiest streak test, the empty one: assert Tracker().streak("read") == 0. The other three each need a new Tracker and a few record calls.

Show one solution
    def average_minutes(self, habit):
        """Mean minutes per recorded day, or 0 when there are none."""
        days = self.days_for(habit)
        if not days:
            return 0
        return round(self.total_minutes(habit) / len(days), 1)

And the streak assertions:

empty = Tracker()
assert empty.streak("read") == 0

one = Tracker()
one.record("read", "2026-01-05", 10)
assert one.streak("read") == 1

broken = Tracker()
for day in ["2026-01-05", "2026-01-06", "2026-01-09"]:
    broken.record("read", day, 10)
assert broken.streak("read") == 2

longest_first = Tracker()
for day in ["2026-01-01", "2026-01-02", "2026-01-03", "2026-01-09"]:
    longest_first.record("read", day, 10)
assert longest_first.streak("read") == 3

print("all streak tests passed")

The last case is the one that catches a real bug: an implementation that returns current instead of longest passes the first three tests and fails this one.

Two more pages

The habit tracker was supported practice: you read a finished program and extended it. That’s valuable, and it’s not the same as starting one from a blank file. Two short pages finish the course:

  • On Your Own — a fresh brief with sample output and no finished program above it. The real test of the week.
  • Come Back Later — review challenges for a week from now and a month from now, because what you can still do then is what you’ve actually learned.

Where to go next

You know the language. What remains is choosing a direction and building things in it. There’s no single “next step”: pick by what you want to make.

If you want to…Learn next
Analyse datapandas, numpy, Jupyter notebooks
Build web APIsFastAPI or Django, plus HTTP basics
Automate your machinepathlib, subprocess, argparse, scheduling
Scrape and call APIsrequests, httpx, BeautifulSoup
Write better Pythonpytest, ruff, mypy, and the itertools docs
Understand the language deeplyFluent Python, and the Python source itself

Two of those directions have courses here. Writing Reliable Python (Intermediate) is for turning scripts into tools that survive messy real-world input: tests, parsing, storage and a command line. Designing Reliable Libraries (Advanced) is narrower — it’s for people who write code that other developers call, and it’s one destination among the ones in the table, not the step everyone should take next. If you want to analyse data or build a web app, the libraries in the table are the better next move.

Three habits matter more than any of those choices:

Build something you actually want. Motivation beats curriculum. A script that renames your photos will teach you more than ten tutorials.

Read other people’s code. The standard library is on your machine and much of it is readable. import random; print(random.__file__) will show you where.

Write it down when it breaks. Every error you understand is one you will never lose an hour to again.

The week, in one page

DayWhat you can now do
1Variables, numbers, strings, f-strings, input and conversion
2Conditions, while and for, break and continue
3Lists, tuples, dictionaries, sets — and choosing between them
4Functions, arguments, scope, lambdas, comprehensions
5Exceptions, files, the standard library, your own environment
6Classes, dunder methods, inheritance, dataclasses, type hints
7Generators, packages, tests, and a program of your own

That is the essential language. Everything else in Python is a library on top of what you have just learned.

Last updated on January 11, 2026

Was this article helpful?

Your response is saved on this device.