Code vs. Cells
Why I've Ditched Excel and Google Sheets and instead built small domain models.
Motivation
I like Domain-driven design, and seeing how to utilize it. A fun exercise is trying to model everyday problems while building them test driven. I've started doing that with simple problems I would previously have solved with a spreadsheet (i.e. Google Sheets). It's great for learning, and I've realized I get a better result.
Calculating velocity
I have on multiple occasions acted as Scrum Master (I'd argue I haven't run scrum, nor that I want to), and the question of velocity comes up. I've previously created excel sheets where each sheet was a sprint, and then I had a "front page" with the summarized calculations. It was a mess when I suddenly wanted to introduce a new datapoint, or reformat a given page - as references to specific cells would suddenly be broken. It was a mess.
Instead, I created Sprinthon. It's a very simple Python tool - Utilizing Pydantic I was able to fletch out the domain model rather quickly. A sprint simply consists of:
from pydantic import BaseModel, validator
class SprintScope(BaseModel):
initial: Optional[StoryPoints]
final: Optional[StoryPoints]
burned: Optional[StoryPoints]
@property
def growth(self) -> Goal:
if self.final and self.initial:
return self.final - self.initial
return None
@property
def spillover(self) -> Goal:
if self.final and self.burned:
return self.final - self.burned
return None
class Sprint(BaseModel):
number: int
scope: Optional[SprintScope] = None
And suddenly I had a list of sprints:
sprints = [
Sprint(
number=2,
scope=SprintScope(
initial=50,
final=55,
burned=45,
),
workdays=5,
),
Sprint(
number=1,
scope=SprintScope(
initial=60,
final=61,
burned=55,
),
workdays=5,
),
]
And getting any sort of statistics out of this is pretty trivial. The average burned story points are pretty easy to get:
avg_burned = avg(sprint.scope.burned for sprint in sprints if sprint.scope),
One could easily make it a Rolling average or something more complex if wanted.
Furthermore, I realized I'd want to add working_days
to the model after we had a sprint that was partly swallowed by vacations. Easy to add to the model:
class Sprint(BaseModel):
number: int
workdays: float | None = None
scope: Optional[SprintScope] = None
def get_daily_velocity(self) -> float | None:
if self.workdays is None or self.scope is None or self.scope.burned is None:
return None
return round(self.scope.burned / self.workdays, 2)
By allowing optional fields, I can easily enforce various backward and future-proofing, and having an IDE, I can easily update the sprints that don't conform. By writing a few unit tests it is easy to validate that my results are what I expect:
def test_has_daily_velocity(some_sprint: Sprint):
some_sprint.scope.burned = 10
some_sprint.workdays = 5
assert some_sprint.get_daily_velocity() == 2
I ended up extending it to create a MarkdownWriter
which means I can export a report of the sprints, which I can convert to PDF or whatever I'd want. That would have been a lot more work in a spreadsheet.
Conclusion
I often forget how easy it is to write code. I often forget how much I enjoy writing code. By using scripting languages (I also wrote a similar tool for another purpose in F#), I can easily help automate the boring stuff. It might be what Automate The Boring Stuff With Python argues as well. But it's quite neat - and by making micro tools they might grow into an open-source package that can help others.
Worst case, you've solved your problem and improved your coding and problem-solving skills in the process.