Unit 15 Project: Plan a Capstone You Will Actually Finish
Big enough to be proud of, small enough to ship
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.
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)
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
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.
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.")
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.
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'}")
Use plan.get("must", []) so a missing key is not a crash. The count check belongs in an elif: an empty list is already reported and should not also be called too big. For “done”, .strip() catches a value that is only spaces.
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 = []
must = plan.get("must", [])
if not must:
problems.append("no must-have stories")
elif len(must) > 5:
problems.append(f"too big: {len(must)} must-haves, keep 5 or fewer")
if len(plan.get("milestones", [])) < 3:
problems.append("too few milestones")
if not plan.get("done", "").strip():
problems.append("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'}")
Everything App: ['no definition of done', 'too big: 6 must-haves, keep 5 or fewer', 'too few milestones']
tidyup: 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.
# 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 ___.
Common mistake: Choosing a project that is too large
The ambitious version is the one that is exciting to imagine.
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
The visible part feels like real progress.
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
It seems obvious at the start, and there is code to write.
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
They are not the interesting part, and the code is not finished yet.
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
It is a genuinely good idea and it is right there.
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?
Done is a decision, made in advance. Left to feel, it never arrives.
What is scope creep?
Each addition is individually reasonable, which is exactly why the LATER list has to be written down.
What should each milestone end with?
A milestone ending in a half-built module leaves you with nothing to show and no proof the pieces connect.
Why build the ugly end-to-end version first?
The hard part is rarely where you expected. Finding that out in milestone one is cheap; finding it in week five is not.
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))
Double each estimate as you go and keep a running total. The week a milestone lands in is math.ceil(cumulative / hours_per_week). Use math.ceil rather than integer division, because 5 hours at 4 hours a week is week 2, not week 1. too_long only needs the week of the final milestone.
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."""
rows = []
cumulative = 0
for name, hours in milestones:
cumulative += hours * 2
rows.append((name, cumulative, math.ceil(cumulative / hours_per_week)))
return rows
def too_long(plan, weeks):
"""Does the plan finish later than the time available?"""
if not plan:
return False
return plan[-1][2] > weeks
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))
skeleton 4h week 1
real moves 10h week 3
tests 16h week 4
packaged 20h week 5
readme 24h week 6
overruns 4 weeks: True
overruns 8 weeks: False
assert schedule([], 4) == [], "no milestones is an empty plan"
assert schedule([("a", 2)], 4) == [("a", 4, 1)], "2h doubles to 4h, which fits in week 1"
assert schedule([("a", 3)], 4) == [("a", 6, 2)], "6h at 4h a week runs into week 2 - use math.ceil, not //"
assert schedule([("a", 1), ("b", 1)], 4)[1][1] == 4, "the hours column is cumulative, not per-milestone"
assert too_long([], 1) is False, "an empty plan cannot overrun"
assert too_long(schedule(MILESTONES, 4), 6) is False, "6 weeks is exactly enough, so it does not overrun"
print("✓ Looks good!")