Turn a raw .ics calendar export into an honest breakdown of how many
hours you actually spent on each class, club, or project — with support
for excluding break weeks from the average and folding in generic
"study"/"lock in" time that isn't tagged to anything specific.
This exists because Google Calendar (or Apple Calendar, or Outlook) knows exactly what you did with your semester, but won't tell you. This tool reads the export, matches event titles against patterns you define, and gives you a table.
## Academic
_Denominator: 14.79 weeks (break weeks excluded)._
| Category | Labeled Hours | Misc Study (+) | Total Hours | Hrs/Week |
|----------------------------------------------|---------------|-----------------|--------------|----------|
| 15-122 Principles of Imperative Computation | 159.43 | 9.33 | 168.76 | 11.41 |
| 18-100 Intro to ECE | 82.58 | 4.83 | 87.41 | 5.91 |
| 21-127 Concepts of Mathematics | 57.08 | 3.34 | 60.42 | 4.09 |
| ... | | | | |
## Extracurricular
_Denominator: 16.00 weeks (break weeks included)._
| Category | Labeled Hours | Total Hours | Hrs/Week |
|----------------|---------------|-------------|----------|
| Lunabotics | 239.25 | 239.25 | 14.95 |
| SPIRIT Buggy | 45.25 | 45.25 | 2.83 |
| Hackathons | 42.75 | 42.75 | 2.67 |
(Real output from examples/vian_spring_2026.yaml — see Example below.)
Two things that most "time tracking from calendar" scripts get wrong:
-
Not every week is a real week. If your semester has Spring Break and a half-week holiday in it, dividing total hours by the raw number of calendar weeks understates how intense your actual weeks were. This tool lets you exclude specific periods from the denominator — per category, since a class stops during break but a robotics team building for a competition usually doesn't.
-
Not everything is labeled. Real calendars are full of events like "Lock In," "Study sesh 🌾🌾," or "S T U D Y" that clearly represent work but don't say what for. Rather than silently dropping that time or lumping it into one category, this tool extracts it separately and distributes it proportionally across your real categories, based on how much labeled time each one already has.
git clone https://github.com/<you>/ics-time-tracker.git
cd ics-time-tracker
pip install -e .Requires Python 3.9+. Dependencies: icalendar, recurring_ical_events, PyYAML.
- Google Calendar: Settings → Settings for my calendars → [your calendar] →
"Export calendar" (downloads a
.zipcontaining the.ics). - Apple Calendar: File → Export → Export...
- Outlook: File → Save Calendar → choose
.ics.
- Copy the template config:
cp config.example.yaml my_config.yaml
- Edit
my_config.yaml: set your date range, list your classes/activities with regex patterns that match how they show up in your calendar, and (optionally) any breaks to exclude and misc-time patterns to sweep up. - Run it:
ics-time-tracker path/to/calendar.ics my_config.yaml
- Iterate. Run with
--show-unmatchedto see everything that didn't get categorized — this is the fastest way to notice a class you forgot to add a pattern for, or a typo'd event title (e.g. "Lunarbotics" vs "Lunabotics" — real example, see below).ics-time-tracker path/to/calendar.ics my_config.yaml --show-unmatched
# Write to a file instead of printing
ics-time-tracker calendar.ics my_config.yaml -o report.md
# CSV, for spreadsheets
ics-time-tracker calendar.ics my_config.yaml --format csv -o report.csvdate_range:
start: 2026-01-05
end: 2026-04-26
excluded_periods:
- name: "Spring Break"
start: 2026-03-02
end: 2026-03-06
- name: "Spring Carnival"
start: 2026-04-09
end: 2026-04-11
weeks: 0.5 # optional override; defaults to (days_in_period / 7)
categories:
academic: # group label -- name it whatever you want
- name: "15-122 Principles of Imperative Computation"
patterns: # regex, matched case-insensitively against event titles
- "Principles of Imperative Computation"
- "\\b122\\b"
# subtract_excluded_periods defaults to true for group "academic"
extracurricular:
- name: "Robotics Club"
patterns:
- "robotics"
subtract_excluded_periods: false # clubs often keep going through breaks
misc_time:
patterns:
- "Lock\\s*[.\\s]*[Ii]n"
- "^Study"
distribute_to: academic # which group's totals to spread this acrossMatching rules:
- Patterns are Python regular expressions, matched case-insensitively.
- Within a category, matching any pattern is enough.
- Across categories, the first one listed that matches wins — so put
specific patterns (
15-?122) before broad ones (\bPG\b) to avoid a greedy category swallowing events that belong elsewhere. - All-day events (no start/end time, just a date) count as 0 hours, since they're usually deadline markers rather than time actually spent.
subtract_excluded_periods (set per category): controls whether that
category's hours-per-week average uses the raw calendar span or the span
with excluded_periods subtracted out. If you don't set it explicitly, it
defaults to true when the category's group is named "academic" and
false otherwise — so either name your groups accordingly, or just set
this field explicitly on every category to be safe.
examples/vian_spring_2026.yaml is a real config from a CMU student's
Spring 2026 semester — 7 classes plus Lunabotics, SPIRIT Buggy, and
hackathons, with Spring Break and Carnival excluded from the class
averages (but not the club averages, since Carnival weekend is Buggy
raceday). Run it against your own .ics as a working reference:
ics-time-tracker your_calendar.ics examples/vian_spring_2026.yaml --show-unmatchedThe pieces are usable directly in Python if you want more control than the CLI gives you:
from datetime import date
from ics_time_tracker import load_events, Category, Categorizer
events = load_events("calendar.ics", date(2026, 1, 5), date(2026, 4, 26))
categories = [
Category(name="15-122", patterns=[r"15-?122"], group="academic"),
Category(name="Lunabotics", patterns=[r"luna"], group="extracurricular",
subtract_excluded_periods=False),
]
result = Categorizer(categories).categorize(events)
print(result.totals) # {"15-122": 168.76, "Lunabotics": 239.25}
print(result.unmatched) # everything that didn't match, for reviewpip install -e ".[dev]"
pytest -vSee CONTRIBUTING.md.
MIT — see LICENSE.