Capstone: Build Something

The Course
January 11, 2026
7 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.

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, include it in report(), and write assertions covering the four streak cases listed above.

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
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.

Where to go next

You know the language. What remains is choosing a direction and building things in it.

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

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.