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
- Requirements
- Install
- Usage
- Output formats
- Example output
- Configuration
- Privacy
- Design notes
- Limitations
- Tests
- License
outlook_hours.ps1asks 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.calendar_hours.pyruns 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.
- 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. Notpwsh. The collector is only tested against 5.1 and the launcher callspowershell. - 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.fromisoformatandsubprocess.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.
git clone https://github.com/pinklightultra/calendar-hours
cd calendar-hours
python calendar_hours.py --helpOptionally put the launcher on your PATH:
chmod +x calendar-hours
ln -s "$PWD/calendar-hours" ~/bin/calendar-hoursThe 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.
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 rangeThe 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.
| 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.
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 personalmeasured 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, minus0.25of overlap, is the1.83on 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 40measured 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.
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.
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.
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.
--jsonadditionally emits anorganizerfield. It comes fromAppointmentItem.Organizerand resolves to a display name or an SMTP address, so raw JSON contains other people's identities. It also carries anattendeescount.--savewrites all of that to a file inoutput_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.
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_secretin 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.
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 exits2and prints the filter it tried, rather than returning a wrong range. - The upper bound of a range is 23:59, not 23:59:59.
ShortTimePatternhas 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.
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 -v28 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.
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.