Skip to content

About

Measure how many hours a week actually went to meetings, from your own Outlook calendar, and print it in a shape you can paste into a timesheet.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

calendar-hours

Measure how many hours a week actually went to meetings, from your own Outlook calendar, and print it in a shape you can paste into a timesheet.

The meeting side of a timesheet is the only part that is recorded somewhere: your calendar knows what you sat in and for how long. calendar-hours reads that, dedupes recurrence expansion, drops declined invites and all-day events, merges overlapping appointments so a day cannot report more hours than it had, and prints a per-day breakdown with the subjects as note text.

It deliberately does not guess anything it cannot measure. If you want a "hours left to allocate" line you have to tell it your weekly target; unset, the line is simply not printed.

Two files, no third-party Python packages, no stored credentials.

What it does

  1. outlook_hours.ps1 asks the local desktop Outlook session, over COM, for every appointment whose start falls in a date range. It emits one JSON row per appointment on stdout and a skip tally on stderr.
  2. calendar_hours.py runs that collector, groups the rows into weeks and days, applies your exclusions, merges overlaps, and renders one of four formats.

The two halves talk over JSON on stdout, which is why the report layer is testable without Outlook and why --from-json can re-render saved rows.

What the collector skips, and why:

skipped reason
Canceled: / Cancelled: subjects the meeting did not happen
ResponseStatus = olResponseDeclined you were not there
all-day events PTO and holidays are not meeting time
BusyStatus failing your busy_policy informational holds are not meetings
duplicate subject + start minute COM recurrence expansion emits case-variant doubles
zero or unreadable duration there is no number to count

Every one of those is counted by reason and reported on stderr. Nothing is dropped silently.

Requirements

  • Windows. This reads a desktop MAPI session over COM. There is no other platform where that exists.
  • Windows PowerShell 5.1, invoked as powershell.exe. Not pwsh. The collector is only tested against 5.1 and the launcher calls powershell.
  • Desktop Outlook installed, running, and signed in. COM answers from the running profile; if Outlook is closed you get an empty read, and the tool says so rather than printing zeros.
  • Python 3.7 or newer. The floor is set by datetime.date.fromisoformat and subprocess.run(capture_output=...), both 3.7. There are no f-strings and no walrus operators, so 3.7 really is the floor.
  • No third-party Python packages. Standard library only.

Install

git clone https://github.com/pinklightultra/calendar-hours
cd calendar-hours
python calendar_hours.py --help

Optionally put the launcher on your PATH:

chmod +x calendar-hours
ln -s "$PWD/calendar-hours" ~/bin/calendar-hours

The launcher resolves calendar_hours.py next to itself, so the repo can live anywhere. It prefers python3 and falls back to python.

Then, optionally, copy calendar-hours.conf.example to calendar-hours.conf and uncomment what you want. Nothing in the config file is required; the tool runs with an empty config.

Usage

Four ways to choose a date range:

calendar-hours                            # the current week
calendar-hours -w 2026-08-19              # the week containing this date
calendar-hours -m 2026-08                 # every week overlapping this month
calendar-hours -r 2026-08-01 2026-08-21   # an explicit inclusive range

The long forms are --week, --month and --range. --month and --week expand to whole weeks, using whatever --week-start you set, so the report always shows complete weeks even when the month cuts one in half.

Other flags:

flag effect
--week-start DAY day a week starts on, monday .. sunday (default monday)
--format NAME plain, openair, csv, markdown (default plain)
--exclude REGEX case-insensitive subject regex to exclude
--busy-policy POLICY which BusyStatus values count
--target HOURS weekly target; enables the allocation line
--no-merge-overlaps count overlapping appointments more than once
--config PATH config file to read
--collector PATH path to outlook_hours.ps1
--output-dir PATH where --save writes
--json print the raw collector rows and stop
--save also write the report into output_dir
--from-json PATH read rows from a JSON file instead of Outlook

--json piped to a file plus --from-json on the way back is the way to re-render a range in another format without asking Outlook twice.

Output formats

format shape
plain default. Per-day appointment list, exact hours, day and week totals
openair quarter-hour grid, plus one blank slot per configured category, for timesheet systems that bill in quarters
csv one row per appointment, with a counted column, for a spreadsheet
markdown tables, for pasting into a wiki or a note

openair is one format among four, not the reason the tool exists. It is about fifteen lines of rendering. If your timesheet system wants something else, FORMATTERS in calendar_hours.py is a dict of name to function.

The categories config key is what fills the blank slots in openair. Those lines are intentionally blank: nothing on your machine records how long you spent authoring versus scripting versus reading, so filling them in is a judgment call, not a lookup. Unset, they are not printed at all.

Example output

Everything below is real output from this code, produced by running --from-json against tests/fixtures/sample_week.json. The fixture is invented: the meeting subjects are things like "Sprocket Review" and the organizers are people like "Ada Placeholder". No real calendar data appears anywhere in this repo.

calendar-hours --from-json tests/fixtures/sample_week.json --exclude personal
measured meeting hours - range 2026-08-17 .. 2026-08-24, weeks start Monday, overlaps merged

==================================================================
WEEK OF 2026-08-17   (2026-08-17 .. 2026-08-23)
==================================================================

--- MON 2026-08-17 ---
  09:00   0.50  Widget Standup
  09:15   0.75  Sprocket Review (overlaps the standup on purpose)
  13:00   0.83  Flange Roadmap Sync
          2.08  = sum of the above
          0.25  - overlapping time counted once
  day total: 1.83 h

--- TUE 2026-08-18 ---
  10:00   0.42  Cogwheel Triage
  14:00   1.50  Thingamajig Design Workshop
  day total: 1.92 h

--- FRI 2026-08-21 ---
  15:00   0.83  Doohickey Retro
  day total: 0.83 h

  excluded by exclude_pattern (1):
    2026-08-19 11:00 1.00h  Personal errand

>> measured meeting total for the week: 4.58 h

==================================================================
WEEK OF 2026-08-24   (2026-08-24 .. 2026-08-30)
==================================================================

  no counted meetings in this week

  excluded by exclude_pattern (1):
    2026-08-24 12:00 0.50h  Personal appointment

>> measured meeting total for the week: 0.00 h

Three things in there are on purpose:

  • The printed numbers reconcile. 0.50 + 0.75 + 0.83 = 2.08, minus 0.25 of overlap, is the 1.83 on the day line. Day lines sum to the week line. If a column does not add up, that is a bug, not rounding.
  • Overlap is shown, not hidden. The Monday standup and the review collided, so fifteen minutes are counted once and the deduction is named.
  • The second week is empty of counted meetings and still gets a block. A week where everything was excluded must not vanish.

The same fixture in openair, with a Saturday-anchored week, three categories and a 40-hour target. This one is cut off after the first day, because the quarter-rounding is the only part worth looking at; the run continues for the rest of the range.

CALENDAR_HOURS_CATEGORIES=$'Project Alpha\nInternal Tooling\nResearch' \
  calendar-hours --from-json tests/fixtures/sample_week.json \
  --exclude personal --format openair --week-start saturday --target 40
measured meeting hours - range 2026-08-17 .. 2026-08-24, weeks start Saturday, overlaps merged
(quarter-hour grid; category lines are blank on purpose - nothing measures them)

==================================================================
WEEK OF 2026-08-15   (2026-08-15 .. 2026-08-21)
==================================================================

--- MON 8/17 ---
[Project Alpha - ____]

[Internal Tooling - ____]

[Research - ____]

[Meetings - 1.75]
Widget Standup 0.50
Sprocket Review (overlaps the standup on purpose) 0.75
Flange Roadmap Sync 0.83
(overlapping time counted once: -0.25 h)
(measured 1.83 h, rounded to the quarter hour)

Quarter-rounding is visible rather than silent: the grid figure is 1.75, and the line underneath says the measured figure was 1.83.

Configuration

Precedence is command-line flag > environment variable > config file > default. The config file is INI, one [calendar-hours] section. It is searched for at --config PATH, then $CALENDAR_HOURS_CONFIG, then ./calendar-hours.conf, then ~/.calendar-hours.conf. First one found wins; none is a perfectly good answer. An unknown key in the file is an error, not a silent no-op.

key environment variable flag behaviour when unset
collector_path CALENDAR_HOURS_COLLECTOR_PATH --collector outlook_hours.ps1 next to calendar_hours.py. The collector is vendored in this repo, so this normally needs no configuration
output_dir CALENDAR_HOURS_OUTPUT_DIR --output-dir the current working directory. Never a path inside the checkout
categories CALENDAR_HOURS_CATEGORIES — empty. No category placeholder lines are printed at all
exclude_pattern CALENDAR_HOURS_EXCLUDE_PATTERN --exclude empty. Nothing is excluded, which is the honest default. An invalid regex is a one-line error, not a traceback
weekly_target_hours CALENDAR_HOURS_WEEKLY_TARGET_HOURS --target unset. The "unallocated against a target" line is dropped entirely rather than guessing a 40-hour week
week_start_day CALENDAR_HOURS_WEEK_START_DAY --week-start monday, the ISO answer. saturday and every other day are available
busy_policy CALENDAR_HOURS_BUSY_POLICY --busy-policy not-free: skip olFree, count tentative. See below
merge_overlaps CALENDAR_HOURS_MERGE_OVERLAPS --no-merge-overlaps true. Overlapping appointments are counted once
output_format CALENDAR_HOURS_OUTPUT_FORMAT --format plain

categories in the config file is a multi-line value, one category per indented continuation line. In the environment variable it is newline separated.

busy_policy

Outlook's BusyStatus is 0 free, 1 tentative, 2 busy, 3 out of office, 4 working elsewhere. Whether a tentative meeting counts as time you spent is a real preference, not a fact, so it is a setting:

value counts
all everything, including free-marked holds
not-free everything except olFree. Default
not-free-or-tentative everything except olFree and olTentative
busy-only only olBusy

Because busy_policy handles free-marked holds and focus blocks, your exclude_pattern only has to cover genuinely personal appointments that live on the work calendar. That filtering happens in the collector, where the tally reports it, so items removed by busy_policy do not appear in the report's excluded list.

Privacy

Read this before you paste output anywhere.

  • The report prints real meeting subjects to stdout. That is the point of the tool: the subject is the note text. It is also the part most likely to be confidential.
  • --json additionally emits an organizer field. It comes from AppointmentItem.Organizer and resolves to a display name or an SMTP address, so raw JSON contains other people's identities. It also carries an attendees count.
  • --save writes all of that to a file in output_dir, in plain text.
  • The collector counts required attendees rather than listing them, on purpose. The headcount tells you what kind of meeting it was; the names are other people's data and there is no reason for this tool to hold them.
  • Nothing leaves your machine. There is no network call anywhere in either file, and no credential is read or stored.

Do not paste this output into a ticket, a chat channel, or a support case without reading it first. Use --exclude for the appointments you never want rendered.

Design notes

COM instead of Microsoft Graph. The obvious way to read a calendar programmatically is the Graph API. That path needs an app registration, a consent grant, a token cache, and it lands you against whatever Conditional Access policy the tenant enforces; in a managed environment you may not be able to get any of those without asking someone. The local desktop MAPI session is already authenticated. Reading it over COM means:

  • zero third-party Python dependencies,
  • zero stored credentials, zero tokens, no client_secret in a dotfile,
  • nothing to get approved.

The cost is real and permanent: Windows only, desktop Outlook only, Outlook has to be running. That is the trade, stated plainly. If you need cross-platform or headless, you need Graph, and this is the wrong tool.

IncludeRecurrences must be set after Sort. In outlook_hours.ps1, $cal.Sort('[Start]') comes before $cal.IncludeRecurrences = $true. Reverse them and recurrence expansion silently does not happen: no error, just a report missing every recurring meeting. The comment on that line stays there for a reason.

Dedupe on lowercased subject plus start minute. COM recurrence expansion emits case-variant duplicates of the same instance, so a daily standup can appear twice with different capitalisation. Keying on subject.ToLower() + start-to-the-minute collapses them. Without it, recurring meetings double-count and the week total comes out high.

Restrict parses dates in the machine locale. Outlook's Restrict filter does not accept a format you choose; it parses date literals using the current culture's short date pattern. A hardcoded MM/dd/yyyy filter on a dd/MM/yyyy machine matches the wrong range and surfaces as "no meetings found", which looks like an empty week. The collector therefore takes unambiguous ISO yyyy-MM-dd input, parses it with the invariant culture, and renders it back out using (Get-Culture).DateTimeFormat before building the filter. On an en-US machine that produces 8/17/2026 12:00 AM; on de-DE, 17.08.2026 00:00.

Failure is loud. A tool whose output is a number you paste into a timesheet has to fail visibly, because a wrong number looks exactly like a right one. So: a missing collector names the path it wanted; a non-zero PowerShell exit prints stderr; non-JSON stdout prints the first 400 characters; an empty stdout says Outlook must be running and signed in; and if the collector found candidate appointments but skipped every single one, it exits 3 and both halves say so instead of reporting a clean zero.

Limitations

Volunteered, in rough order of how likely they are to matter to you:

  • Windows plus desktop Outlook only, and Outlook has to be running. See the design note above. There is no Graph fallback and adding one would undo the zero-credentials property.
  • Recurrence exceptions are trusted as Outlook reports them. The collector reads expanded instances; if the local store's expansion is wrong, so is the report.
  • Overlaps are merged, which is a choice. Two meetings from 09:00 to 10:00 count as one hour, not two, because you only had one hour. If your timesheet system genuinely wants both, pass --no-merge-overlaps. Overlapping appointments that cross midnight are attributed entirely to their start date.
  • Free/tentative policy is a preference with no correct answer. The default counts tentative meetings as attended. If you routinely leave invitations tentative and skip them, use --busy-policy not-free-or-tentative.
  • Declined-then-attended is invisible. If you declined an invitation and went anyway, the collector drops it, because the only record says you declined.
  • The locale hazard is handled, not eliminated. The filter is now built from the machine's own short date pattern, which is the documented behaviour of Restrict. A profile with an unusual custom short-date pattern could still produce a literal Outlook rejects; the collector exits 2 and prints the filter it tried, rather than returning a wrong range.
  • The upper bound of a range is 23:59, not 23:59:59. ShortTimePattern has no seconds field. An appointment starting in the final minute of the last day would be missed.
  • The report layer has tests; the collector does not. Testing the collector means talking to COM, and there is no fixture for that. Its logic is deliberately flat for that reason.
  • Only the default calendar folder (GetDefaultFolder(9)) is read. Shared and secondary calendars are ignored.
  • Duration is trusted. If Outlook reports a duration that disagrees with start and end, the reported duration wins.

Tests

The report and format layer is tested against a JSON fixture, so the suite needs no Outlook, no COM and no network:

python -m unittest discover -s tests -v

28 tests. They cover week anchoring for all seven start days, interval merging, totals reconciliation, the fully-excluded week case, the no-target and over-target cases, all four formats, and the config defaults. fetch() is not exercised, because exercising it would mean opening a real calendar.

License

MIT. See LICENSE.

Built while running documentation for a healthcare SaaS platform, where the timesheet grid billed in quarter hours and the only honest source for meeting time was the calendar itself.

About

Measure how many hours a week actually went to meetings, from your own Outlook calendar, and print it in a shape you can paste into a timesheet.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages