Unit 15 Project: Plan a Capstone You Will Actually Finish

Big enough to be proud of, small enough to ship

Advanced 17 min

In this lesson

Every unit so far ended with a project you were handed. This one ends with a project you choose — and the work here is the planning, because that is the part that decides whether it gets finished.

Unfinished projects almost never fail on the code. They fail because the scope was never fixed, so there was no moment at which the thing was done.

Explain it like I’m 5

A capstone plan is a map for a project big enough to be proud of and small enough to actually finish.

Pick a track, then pick something you want

Three routes, each using a different part of what you have learned. Pick the one that matches the work you want to do next.

  • Automation tool (Units 5, 8, 9, 15) — a program driven by command-line arguments that does a chore for you. Rename and sort files, back something up, watch a folder, generate a weekly report. Ships as: an installable command.
  • Data analysis (Units 7, 8, 12) — pull real data from an API or a CSV, clean it, and answer a question with a chart. Ships as: a notebook or a script that writes a one-page summary.
  • Web app (Units 6, 7, 8) — a small Flask app, built on a web framework, with a SQL database behind it. A links page, a reading list, a tiny tracker. Ships as: something running that another person can open.

Then the rule that matters more than the track: build something you personally want to exist. Motivation is the scarce resource in a project nobody assigned you, and wanting the result is the only reliable supply.

Write it as user stories, not as features

A feature list is a list of things to build. A user story is a sentence about someone getting something they wanted, and it is more useful because it can be finished.

The form is: As a <kind of person>, I want <something>, so that <reason>. The last clause is the one that earns its place: it is what lets you notice that a feature does not serve any actual purpose.

Stories also give you the test list for free. “I want to see what would happen before it happens” is both a story and a description of --dry-run.

Example
STORIES = [
    "As someone with a chaotic downloads folder, I want files grouped by month,\n"
    "  so that I can find last March's invoice.",
    "As a cautious person, I want a dry-run mode,\n"
    "  so that I can see what would move before anything does.",
    "As someone who has been burned, I want name clashes handled,\n"
    "  so that a second report.pdf never overwrites the first.",
]

NICE_TO_HAVE = [
    "undo the last run",
    "a config file for custom rules",
    "a progress bar",
]

print("MUST (this is the project):")
for story in STORIES:
    print(" -", story)

print(f"\nLATER ({len(NICE_TO_HAVE)} items, deliberately not now):")
for item in NICE_TO_HAVE:
    print(" -", item)
Output
MUST (this is the project):
 - As someone with a chaotic downloads folder, I want files grouped by month,
  so that I can find last March's invoice.
 - As a cautious person, I want a dry-run mode,
  so that I can see what would move before anything does.
 - As someone who has been burned, I want name clashes handled,
  so that a second report.pdf never overwrites the first.

LATER (3 items, deliberately not now):
 - undo the last run
 - a config file for custom rules
 - a progress bar
Three stories are the project. The other three are written down so they stop nagging.

Define done before you start

Write down, in advance and in one sentence, what has to be true for the project to be finished. Without it, “done” is a feeling, and the feeling never arrives.

A usable definition of done for a capstone:

  • Every must-have story works on real data, not a sample you invented.
  • There are tests on the parts that decide something, and they pass.
  • The README tells a stranger what it does and how to run it.
  • It runs somewhere that is not your editor — installed as a command from a pyproject.toml, or deployed, or run from a fresh clone in a new virtual environment.
  • You know what you would add next, and you are choosing not to.

Note what is not on that list: polish, every edge case, and being proud of all the code. Those are how projects become permanent.

Milestones that each end in something that runs

Split the work so that every milestone ends with a program you can execute. Not “write the database layer”, which ends with nothing you can look at, but “save one record and read it back”.

This matters for two practical reasons. You always have something demonstrable, so an interruption cannot leave you with nothing. And the plan gets tested early: the hard part is usually not where you expected, and you find that out in milestone two rather than in week five.

The order that works: make the ugliest version work end to end first, then improve it. Hard-code the input, print instead of writing files, skip the error handling — but get from start to finish. A working ugly version is a project; three beautiful pieces that do not connect are not.

Example
MILESTONES = [
    ("walking skeleton", "hard-coded folder, prints what it would move", "2h"),
    ("real moves",       "actually moves files, --dry-run flag works",  "3h"),
    ("clashes + tests",  "no overwrites, pytest covers the naming rules", "3h"),
    ("packaged",         "pip install -e . and `tidyup` runs anywhere",  "2h"),
    ("README + polish",  "docs, screenshot, and the LATER list written", "2h"),
]

total = 0
for number, (name, ends_with, estimate) in enumerate(MILESTONES, start=1):
    hours = int(estimate.rstrip("h"))
    total += hours
    print(f"{number}. {name:18s} {estimate:>3s}  -> {ends_with}")

print(f"\nestimate: {total}h. Real projects take about twice the estimate,")
print(f"so plan for {total * 2}h and be pleased if it is less.")
Output
1. walking skeleton    2h  -> hard-coded folder, prints what it would move
2. real moves          3h  -> actually moves files, --dry-run flag works
3. clashes + tests     3h  -> no overwrites, pytest covers the naming rules
4. packaged            2h  -> pip install -e . and `tidyup` runs anywhere
5. README + polish     2h  -> docs, screenshot, and the LATER list written

estimate: 12h. Real projects take about twice the estimate,
so plan for 24h and be pleased if it is less.
Five milestones, each ending in something that runs.

Write the plan checker, then use it on your own plan. check(plan) returns a sorted list of problems: no must-have stories, more than five of them, a milestone list shorter than three, or a missing “done” definition. A sound plan returns an empty list.

TOO_BIG = {
    "title": "Everything App",
    "must": ["accounts", "billing", "chat", "search", "reports", "mobile app"],
    "milestones": ["build it"],
    "done": "",
}
GOOD = {
    "title": "tidyup",
    "must": ["group by month", "dry-run mode", "handle name clashes"],
    "milestones": ["skeleton", "real moves", "tests", "packaged"],
    "done": "all three stories work on my real Downloads folder, tests pass",
}

def check(plan):
    """Return a sorted list of problems with this plan."""
    problems = []
    # TODO: no must-haves at all
    # TODO: more than five must-haves ("too big: N must-haves, keep 5 or fewer")
    # TODO: fewer than three milestones ("too few milestones")
    # TODO: an empty or missing "done" ("no definition of done")
    return sorted(problems)

for plan in (TOO_BIG, GOOD):
    found = check(plan)
    print(f"{plan['title']}: {found if found else 'ready to start'}")

Now fill in your own

The worksheet, in full. Copy it, fill it in, and keep it in the repository as PLAN.md so it is next to the code it describes.

Example · PLAN.md
# Capstone plan

Track:        automation / data / web  (pick one)
Working title:
One sentence: I am building ___ so that ___.

## Must have (3-5 stories, this IS the project)
1. As a ___, I want ___, so that ___.
2.
3.

## Later (write them down; do not build them)
-

## Done when
- [ ] every must-have works on real data
- [ ] tests pass on the parts that decide something
- [ ] the README explains it to a stranger
- [ ] it runs from a fresh clone / installs as a command

## Milestones (each ends in something that RUNS)
1. walking skeleton, end to end, ugly   (__h)
2.                                       (__h)
3.                                       (__h)

Estimate: __h.  Realistic: double it.

## Known risks
- The part I am least sure about is ___.
- I will find out whether that works in milestone ___.
Copy this into PLAN.md and fill it in before writing any code.

Common mistake: Choosing a project that is too large

Why it happens:

The ambitious version is the one that is exciting to imagine.

How to fix it:

Cut the story list until it is three items. You can always build version two, and you will only build it if version one exists.

Common mistake: Starting with the interface instead of the logic

Why it happens:

The visible part feels like real progress.

How to fix it:

Get the ugly end-to-end version working first. A beautiful front end on top of nothing is the most common shape of an abandoned project.

Common mistake: Never defining what done means

Why it happens:

It seems obvious at the start, and there is code to write.

How to fix it:

Write the sentence before you begin. Without it every finished feature reveals another one, and the project quietly becomes permanent.

Common mistake: Leaving tests and the README until the end

Why it happens:

They are not the interesting part, and the code is not finished yet.

How to fix it:

By the end you will have moved on. Write the README first. If you cannot describe it in three sentences, the scope is not clear enough to start.

Common mistake: Adding an idea the moment it arrives

Why it happens:

It is a genuinely good idea and it is right there.

How to fix it:

Put it on the LATER list. That is not a rejection, it is a queue — and it is the only defense against a plan that grows faster than you build.

Why write a definition of done before starting?

What is scope creep?

What should each milestone end with?

Why build the ugly end-to-end version first?

Mini exercise (hard)

Turn the milestone plan into something that answers the question you will actually ask: can I finish this? Write schedule(milestones, hours_per_week), which doubles each estimate (because estimates are optimistic), accumulates them, and returns a list of (name, cumulative_hours, week). Then too_long(plan, weeks) reports whether the plan overruns the time you have.

Your turn. Fill in the code below and press Run to test it right here, nothing to install.

import math

MILESTONES = [
    ("skeleton", 2), ("real moves", 3), ("tests", 3),
    ("packaged", 2), ("readme", 2),
]

def schedule(milestones, hours_per_week):
    """(name, cumulative hours, week) with every estimate doubled."""
    # TODO: double each estimate, keep a running total
    # TODO: the week is math.ceil(cumulative / hours_per_week)
    return []

def too_long(plan, weeks):
    """Does the plan finish later than the time available?"""
    return False   # TODO

plan = schedule(MILESTONES, 4)
for name, hours, week in plan:
    print(f"{name:12s} {hours:3d}h  week {week}")

print("overruns 4 weeks:", too_long(plan, 4))
print("overruns 8 weeks:", too_long(plan, 8))

What to learn next

You have a plan rather than an intention: a track, three must-have user stories with the ones you are not building written down beside them, a definition of done fixed in advance, and milestones that each end in a program you can run. You also wrote the checker that catches the two ways a plan goes wrong: too many must-haves, and no agreed finish line.

That completes Unit 15, and with it everything around the code: debugging, testing, style, packaging, CI, containers, and how to show the work. Unit 16 opens by choosing which direction to take all of it, and it ends by getting the capstone you just planned actually shipped.