Skip to content

feat(coverage): emit the hourly activity histogram - #60

Open
Sarcastic-Soul wants to merge 1 commit into
nossa-y:mainfrom
Sarcastic-Soul:feat/surface-hourly-activity-histogram
Open

feat(coverage): emit the hourly activity histogram#60
Sarcastic-Soul wants to merge 1 commit into
nossa-y:mainfrom
Sarcastic-Soul:feat/surface-hourly-activity-histogram

Conversation

@Sarcastic-Soul

Copy link
Copy Markdown
Contributor

Not a bug fix, so no issue — this is a small feature, raised directly as a PR.

What I found

Coverage.hour_histogram — local hour → active minutes — has been computed since the module was written and read by nothing. Grepping src/, tests/, and the docs, it's written at sessionize.py:333 and consumed nowhere. build_frames builds its coverage dict field by field and skips it; no emitter mentions it; SPEC.md §3 doesn't list it.

It isn't free, either. This line in coverage()'s per-frame loop:

local = datetime.fromtimestamp(f.epoch).astimezone()

exists solely to fill that histogram. Measured at 2.9µs/frame against 0.05µs for the minute-bucket arithmetic beside it — roughly 60ms of dead work on a 20k-frame day.

Why surface it rather than delete it

Deleting is the smaller diff, but Coverage is exported in __all__, so dropping a public dataclass field is an API break for anyone reading it directly. And the measure is genuinely useful — "when did the active minutes actually fall" is a question the document can't currently answer without walking every frame and re-deriving the shape.

So: emit it.

The change

"active_minutes_by_hour": cov.hour_histogram,

added to the coverage dict in build_frames. Named to sit beside the existing active_minutes rather than carrying the internal hour_histogram name into the public schema.

Three things follow for free:

  • get_day_summary picks it up with no change, since it returns d["coverage"] whole.
  • JSON and YAML carry it automatically. Cost is at most 24 integer entries.
  • context_block and to_markdown are unaffected — both select named fields, so the compact agent-facing block doesn't grow.

Example, from the test fixture:

"active_minutes_by_hour": {"0": 15, "22": 30, "23": 15}

which sums to the active_minutes: 60 reported alongside it.

Docs

SPEC.md §3 gains the field with the one caveat worth stating explicitly:

An absent hour means nothing was captured then, which is not the same as an hour the user spent idle — the gaps list is what distinguishes them.

That matches the section's existing framing ("consumers must treat anything outside covered time as unknown, not as inactivity"). CHANGELOG.md gets an ### Added entry under [Unreleased].

Tests

Three in tests/test_sessionize.py:

  • test_hour_histogram_partitions_active_minutes — the invariant that makes the field trustworthy: every active minute lands in exactly one hour bucket, so the values sum to active_minutes rather than double-counting at bucket edges.
  • test_hour_histogram_reaches_the_document — emitted value matches coverage() exactly, and still sums to the reported total.
  • test_hour_histogram_empty_window_is_empty — empty window yields {}, not a missing key or 24 zeroes.

Full suite: 110 passed (107 before, +3).

Compatibility

Purely additive: one new key in coverage. No existing field changes name, type, or value, and schema_version stays at 1 — consistent with how the schema has grown before (wall_min, interruptions, omitted).

One note: this is the only one of my six PRs that touches CHANGELOG.md, since it's the only one changing the documented schema. I left the changelog alone in the others so six branches wouldn't all collide on the same file — happy to add entries for them if you'd rather they carried their own.

Coverage.hour_histogram (local hour -> active minutes) has been computed
since the module was written and read by nothing: not by build_frames, not
by any emitter, not by the docs. The per-frame
datetime.fromtimestamp().astimezone() call in coverage() exists solely to
fill it.

Rather than delete a genuinely useful measure -- Coverage is exported in
__all__, so removing the field is a small API break -- surface it as
coverage.active_minutes_by_hour. It is additive to the schema and reaches
get_day_summary for free, since that tool returns the coverage dict whole.

Documents the field in SPEC.md section 3, including that an absent hour
means "not captured", not "idle" -- the gaps list is what tells those apart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant