Refactoring the Study Log with Classes
Learning Objectives
- You can introduce a
Sessionclass and aStudyLogclass into the study log, and you can move the summaries ontoStudyLogas methods. - You can route saving and loading through serialization at the file boundary.
- You can carry out the migration as a behaviour-preserving refactor, justified by the data-design judgment from the previous chapter.
This is the project checkpoint for this part. It assumes that you have already walked through the migrations where you turned sessions into dictionaries and moved storage to JSON. This one introduces classes to the project: a Session for individual records and a StudyLog for the collection, with the summaries becoming methods on StudyLog.
You have already seen these classes in the previous chapters. The focus here is on putting the pieces together into a real program, and making design decisions about where classes belong and where they do not.
The Plan
The plan is to create two classes. The classes are:
A Session class for one record. A session has a fixed, known shape — always a subject, minutes, and a date. It also has natural behaviour: displaying itself, and judging whether it was long.
A StudyLog class for the collection. A study log holds a list of sessions, and it can answer questions about them — how many minutes in total, what are the per-subject summaries, which day was heaviest, what is the current streak.
What stays a plain shape: the results of the summaries. The per-subject summaries are a dictionary of small dictionaries; the heaviest day is a small record; the recent-days breakdown is a list. These are intermediate results computed on demand, throwaway code that should not be a class.
The Session Class
Pull together the Session from the classes chapters — fields, behaviour, and serialization:
class Session: def __init__(self, subject, minutes, date): self.subject = subject self.minutes = minutes self.date = date
def is_long(self): return self.minutes >= 60
def __str__(self): return f"{self.subject.title()}: {self.minutes} minutes on {self.date}"
def to_dict(self): return { "subject": self.subject, "minutes": self.minutes, "date": self.date, }
def session_from_dict(data): return Session(data["subject"], data["minutes"], data["date"])This is the whole record type: named fields from __init__, behaviour in is_long and __str__, the to_dict half of serialization, and the session_from_dict function for the rebuild direction.
Two Kinds of Summary Code
Before writing StudyLog, there is a need for a design decision. There are multiple types of summaries, and they do not all belong in the same place.
Some are questions you ask the log: how many minutes in total, what are the per-subject summaries, which day was heaviest, what is the current streak. These are behaviour about the whole collection, and they become methods on StudyLog.
Others are generic operations on any list of sessions: collecting the distinct days, totalling minutes per day, filtering to a date window. These do not need to be about a particular log — they would work on any list of sessions you handed them. They stay as free-standing helper functions, which the methods in StudyLog can use.
Forcing both questions and helpers onto
StudyLogwould bloat the class with general-purpose tools that are not really about one log. So: the helpers are functions; the questions about the log are methods.
The Helper Functions
These three are the helper functions, with field access changed from row positions to Session attributes. distinct_days reduces a list of sessions to the sorted, de-duplicated dates it covers:
from datetime import date, timedelta
def distinct_days(sessions): """Return the distinct session dates as date objects, sorted ascending.""" day_strings = [] for session in sessions: if session.date not in day_strings: day_strings.append(session.date)
days = [] for day_string in day_strings: days.append(date.fromisoformat(day_string))
days.sort() return daystotals_by_day groups minutes by date, returning [date_string, total] rows in date order:
def totals_by_day(sessions): """Return [date_string, total_minutes] rows, sorted by date ascending.""" days_seen = [] for session in sessions: if session.date not in days_seen: days_seen.append(session.date) days_seen.sort()
result = [] for day in days_seen: total = 0 for session in sessions: if session.date == day: total += session.minutes result.append([day, total]) return resultsessions_between keeps the sessions whose dates fall in an inclusive window. In the previous part it rebuilt rows; now it simply keeps the Session objects that qualify:
def sessions_between(sessions, start, end): """Return the sessions whose dates fall between start and end (both inclusive).""" result = [] for session in sessions: day = date.fromisoformat(session.date) if start <= day <= end: result.append(session) return resultEach reads session.date and session.minutes, where the earlier version unpacked positions or indexed a dictionary.
The StudyLog Class
The StudyLog class holds the sessions and answers questions about them. Its __init__ takes the list of sessions; the summary methods read self.sessions and, where useful, delegate to the helpers above.
class StudyLog: def __init__(self, sessions): self.sessions = sessions
def total_minutes(self): total = 0 for session in self.sessions: total += session.minutes return total
def summaries_by_subject(self): counts = {} totals = {} for session in self.sessions: subject = session.subject counts[subject] = counts.get(subject, 0) + 1 totals[subject] = totals.get(subject, 0) + session.minutes
result = {} for subject in counts: result[subject] = { "count": counts[subject], "total": totals[subject], "average": totals[subject] / counts[subject], } return result
def heaviest_day(self): totals = totals_by_day(self.sessions) if len(totals) == 0: return None best = totals[0] for row in totals[1:]: if row[1] > best[1]: best = row return best
def recent_daily_totals(self, today=None): if today is None: today = date.today() start = today - timedelta(days=6) recent = sessions_between(self.sessions, start, today) return totals_by_day(recent)
def current_streak(self, today=None): if today is None: today = date.today() days = distinct_days(self.sessions) if len(days) == 0: return 0 if days[-1] > today: raise ValueError( f"Session date {days[-1]} is in the future relative to today {today}." ) latest = days[-1] gap = (today - latest).days if gap > 1: return 0 streak = 1 for index in range(len(days) - 2, -1, -1): if (days[index + 1] - days[index]).days == 1: streak += 1 else: break return streak
def longest_streak(self): days = distinct_days(self.sessions) if len(days) == 0: return 0 current = 1 longest = 1 for index in range(1, len(days)): if (days[index] - days[index - 1]).days == 1: current += 1 else: current = 1 if current > longest: longest = current return longestThe shape of each method is similar to the summary functions from the previous part with two changes. The sessions parameter is gone — a method reads self.sessions, because the log holds its own sessions. And field access is attribute access on a Session: session.minutes and session.subject, not session["minutes"] or row[1].
The algorithms are the ones you wrote in the previous part and ported again in the early chapters of this part; nothing about them is new.
A few things worth pointing out:
-
The method
summaries_by_subjectreturns a plain dictionary whose values are themselves plain dictionaries —{"math": {"count": 3, "total": 130, "average": 43.33}, ...}. Not aSummaryclass. The result is an intermediate value the dashboard will iterate over once and discard, so plain dictionaries are the right shape and a class would be unnecessary overhead. -
The methods
heaviest_dayandrecent_daily_totalsdelegate to the helper functions rather than re-implementing them. The methodheaviest_daycallstotals_by_day(self.sessions)and then runs the “best so far” loop over the result. The methodrecent_daily_totalsfilters withsessions_betweenand totals the result withtotals_by_day. -
The method
current_streakkeeps itstoday=Noneparameter, because that parameter was about testability, not about the collection — a test needs to pin down what “today” is, and the default ofNonefalls back to the real date in normal use.
Because every method’s behaviour is unchanged, the tests still apply, once their fixtures build Session objects and wrap them in a StudyLog.
Saving and Loading
Loading a study log consists of reading JSON into dictionaries, then building a Session from each dictionary, and finally putting the list of sessions into a new StudyLog.
import json
def load_study_log(path): """Load a StudyLog from a JSON file. An empty log if the file is missing.""" try: f = open(path, encoding="utf-8") except FileNotFoundError: return StudyLog([]) with f: data = json.load(f)
sessions = [] for item in data: sessions.append(session_from_dict(item)) return StudyLog(sessions)Saving goes the other way: convert each Session to a dictionary, write the list as JSON:
def save(self, path): data = [] for session in self.sessions: data.append(session.to_dict()) with open(path, "w", encoding="utf-8") as f: json.dump(data, f, indent=2)The boundary is exactly where the serialization chapter put it — the file is JSON, the program is objects, and the conversion happens only in load_study_log and StudyLog.save.
Adding a session to a study log would be a small method too, which keeps the “create, then save” flow in one place:
def add(self, session): self.sessions.append(session)The Dashboard
The dashboard from the previous part was a free-standing print_summary(sessions, today=None). With a StudyLog, it becomes a method that reads the log’s own data and calls the log’s own summary methods:
def print_dashboard(self, today=None): if today is None: today = date.today()
if len(self.sessions) == 0: print("No sessions logged yet -- ready to start.") return
total = self.total_minutes() hours, mins = divmod(total, 60) print(f"So far: {len(self.sessions)} sessions, {hours} hours {mins} minutes") print()
print("By subject:") summaries = self.summaries_by_subject() for subject in sorted(summaries): info = summaries[subject] count = info["count"] word = "session" if count == 1 else "sessions" print(f" {subject.title()}: {count} {word}, {info['total']} minutes, " f"average {info['average']:.1f} minutes") print()
recent = self.recent_daily_totals(today) if len(recent) > 0: print("Recent days:") for day, day_total in recent: print(f" {day}: {day_total} minutes") print()
heavy = self.heaviest_day() if heavy is not None: print(f"Heaviest day: {heavy[0]} ({heavy[1]} minutes)")
print(f"Current streak: {self.current_streak(today)} days") print(f"Longest streak: {self.longest_streak()} days")Every summary is now self.total_minutes(), self.summaries_by_subject(), and so on — a question asked of the log, rather than a free-standing call with sessions passed in. The dashboard reads as a description of what a study log shows about itself, which is what it is.
Two details carry forward from the previous part. The sorted(summaries) loop sorts the subjects before printing — a dictionary does not promise a useful order, so when display order matters you sort explicitly. And the "session" if count == 1 else "sessions" picks the right word so a single session does not read as “1 sessions”. For the usual five-session fixture, with today set to 2026-05-12, the method prints exactly the dashboard from the start of the part:
So far: 5 sessions, 4 hours 10 minutes
By subject: History: 1 session, 30 minutes, average 30.0 minutes Math: 3 sessions, 130 minutes, average 43.3 minutes Physics: 1 session, 90 minutes, average 90.0 minutes
Recent days: 2026-05-10: 135 minutes 2026-05-11: 90 minutes 2026-05-12: 25 minutes
Heaviest day: 2026-05-10 (135 minutes)Current streak: 3 daysLongest streak: 3 daysThe Main Script
The top-level script gets noticeably simpler, because the StudyLog now carries the pieces that used to be loose:
from datetime import date
SESSIONS_FILE = "sessions.json"
print("=== Study Log ===")print()
log = load_study_log(SESSIONS_FILE)log.print_dashboard()print()
while True: subject, minutes = get_session() if subject is None: break
today_str = date.today().isoformat() log.add(Session(subject, minutes, today_str)) log.save(SESSIONS_FILE)
# ... per-session feedback as before ...The function load_study_log returns a StudyLog; the dashboard is a method on it; adding a session creates a Session and hands it to log.add; saving is log.save. The script reads as a sequence of operations on a study log object, rather than a set of functions threaded together by a sessions list passed from one to the next.
These are large changes to a working program. The way to make it without breakage is refactoring: change one piece at a time, run tests after each step.
The order in which the changes are done matter, as some pieces depend on others. One possible order is:
- Define
Sessionand confirm its round-trip test passes. - Define
StudyLogwith__init__and one method, and test that method against aStudyLogbuilt from a known fixture. - Move the study log methods over one at a time, running the corresponding tests, which have been adapted to build
Sessionobjects and aStudyLog. - Switch loading and saving to the object versions and confirm a save-then-load round trip returns an equal log.
Only when every method is moved and tested does the dashboard switch to methods and the main script switch to the object flow.
Because the behaviour is unchanged at every step, the tests are the safety net. A test that passed in the previous part and fails after a move means the move changed behaviour — which a refactor must not — and points you straight at the step that did it.
Study log class refactor
0 / 60 points
Refactor the study log project into one complete class-based main.py program.
This checkpoint builds on top of the exercise Study log JSON storage from an earlier chapter in this part. The file format and user-facing behaviour should stay familiar, but the internal shape changes: session dictionaries become Session objects, the collection becomes a StudyLog object, and the summary functions that ask questions about the whole log become methods on StudyLog.
The starter file is intentionally blank. Start from your completed Study log JSON storage solution and refactor it without changing the stored JSON shape or the printed dashboard behaviour.
Store the data in sessions.json in the current working directory. The file must contain a JSON list of dictionaries:
[ {"subject": "math", "minutes": 45, "date": "2026-05-10"}, {"subject": "history", "minutes": 30, "date": "2026-05-11"}]Required classes and helpers:
| Item | Contract |
|---|---|
Session(subject, minutes, date) | stores subject, minutes, and date attributes |
Session.is_long(self) | returns True when minutes is at least 60 |
Session.__str__(self) | returns Subject: MINUTES minutes on DATE, with the subject title-cased |
Session.to_dict(self) | returns {"subject": subject, "minutes": minutes, "date": date} |
session_from_dict(data) | returns a new Session from a dictionary with subject, minutes, and date keys |
distinct_days(sessions) | returns sorted distinct date objects from a list of Session objects |
totals_by_day(sessions) | returns sorted [date_string, total_minutes] rows |
sessions_between(sessions, start, end) | returns the original Session objects inside the inclusive date window |
StudyLog(sessions) | stores the session list as self.sessions |
StudyLog.add(self, session) | appends a Session to the log |
StudyLog.total_minutes(self) | returns total minutes, or 0 for an empty log |
StudyLog.summaries_by_subject(self) | returns {subject: {"count": count, "total": total, "average": average}} |
StudyLog.heaviest_day(self) | returns the earliest [date_string, total_minutes] row with the largest total, or None |
StudyLog.recent_daily_totals(self, today=None) | returns totals for today and the previous six days |
StudyLog.current_streak(self, today=None) | returns the current streak; yesterday still counts; future dates raise ValueError |
StudyLog.longest_streak(self) | returns the longest run of consecutive session days, or 0 |
StudyLog.save(self, path) | writes the log as JSON using UTF-8 |
load_study_log(path) | returns a StudyLog; returns an empty log for a missing file; lets invalid JSON raise |
StudyLog.print_dashboard(self, today=None) | prints the opening dashboard |
classify_session(minutes), get_session(), and main() | keep the user-facing session-entry flow from earlier parts |
The dashboard must match this shape. For this log and today set to May 12, 2026:
StudyLog([ Session("math", 45, "2026-05-10"), Session("history", 30, "2026-05-10"), Session("math", 60, "2026-05-11"), Session("physics", 90, "2026-05-12"),])print_dashboard(today=date(2026, 5, 12)) must print exactly:
So far: 4 sessions, 3 hours 45 minutes
By subject: History: 1 session, 30 minutes, average 30.0 minutes Math: 2 sessions, 105 minutes, average 52.5 minutes Physics: 1 session, 90 minutes, average 90.0 minutes
Recent days: 2026-05-10: 75 minutes 2026-05-11: 60 minutes 2026-05-12: 90 minutes
Heaviest day: 2026-05-12 (90 minutes)Current streak: 3 daysLongest streak: 3 daysFor an empty log, print exactly:
No sessions logged yet -- ready to start.Complete program behavior:
- define
SESSIONS_FILE = "sessions.json", - do not ask for input when the file is imported,
- guard the input loop with
if __name__ == "__main__": main(), - print
=== Study Log ===at startup, - load the existing JSON log before asking for input,
- call
log.print_dashboard()before asking for a new session, - keep asking for subjects until the user types
done, - accept variants such as
Done,DONE, anddone, - ask for minutes only after a non-quit subject,
- re-prompt with
Please enter a whole number of minutes.after non-numeric minutes, - create a new
Session(subject, minutes, date.today().isoformat())for each recorded session, - call
log.add(...)and save immediately after every successfully recorded session, - keep the existing response lines for short, solid, and long sessions,
- and print an end-of-run
=== Summary ===using the finalStudyLog.
Required response lines, with the actual subject substituted:
Short session on math -- every bit counts. Solid session on math. Long session on math -- remember to take breaks!Use the json module for JSON. Do not use the csv module for this checkpoint. Do not use input(...) outside get_session() or main().
Using AI on This Migration
The migration is a natural place to think about combining the comparing-approaches skill with the data-design skill from the previous chapter. A workflow:
- Decide the shapes first. Before writing methods, settle which things are classes (
Session,StudyLog) and which stay plain (the summary results). This is the data-design judgment, and making it deliberately is the point — not defaulting to “everything is a class” because the part is about classes. - Move one method, then ask for a critique. Move
summaries_by_subjectontoStudyLog, then ask an assistant whether the per-subject result should stay plain dictionaries or become a class. Weigh the answer against how the dashboard actually uses it. - Verify against the tests. Each moved method from part 5 has a test. Adapt the fixture to objects and run it. A disagreement is a bug in the move.
- Check the boundary explicitly. Save a log, load it back, and confirm the result is equal to what you saved. Serialization bugs hide here, and a round-trip test flushes them out.
The assistant is useful for the mechanical move and for proposing alternatives; the shape decisions and the verification are yours.
In the study log project, however, there are specific expectations for the shapes of the data and the behaviour. The assistant can suggest alternatives, but you need to check them against the assignment requirements.
What You Have Built
The study log is now an object with a clear structure: a StudyLog that holds Session objects, answers questions about them through methods, and persists itself to JSON through a thin serialization layer at the file boundary. The data is named all the way down — session.minutes, not row[1] — and the behaviour lives with the data it concerns.
Check Your Understanding
- Why are
SessionandStudyLogclasses, while the result ofsummaries_by_subjectis left as plain dictionaries? - Why do the summary methods on
StudyLogreadself.sessionsinstead of taking asessionsparameter the way the Part 5 functions did? - Where in the program does conversion between objects and JSON happen, and where in the program does it deliberately not happen?
- Why does
current_streakkeep itstoday=Noneparameter even after it becomes a method? - The migration is done one piece at a time, running the tests after each step. Why is that the safe way, and what does a newly failing test tell you?
AI Study Aid
Create a chapter diagram
We're looking into to what extent AI-generated diagrams could help with studying.
Use this study aid to generate an AI-generated visual summary of the material you just studied.
Each diagram style emphasizes the content in a different way, so you can choose the focus
that feels most useful.
Using the diagram generator is voluntary and does not affect your course progress. We encourage
you to try it out and see if it helps, but it's totally up to you! Your answers help us understand
how to make better study aids in the future.