The Total Stopped at 100 Titles
An ActivityWatch report summed capped title groups to calculate its total. The query already returned the right answer.
In a synthetic test of ActivityWatch’s Python report command, 100 distinct active window titles, each lasting ten seconds, produce a total of 16 minutes 40 seconds. With 1,000 titles, the command reports the same total.
The missing two and a half hours are still in the query’s input. The report calculates its total from a list that has already been cut to 100 groups.
I reproduced this in aw-client report HOST and opened ActivityWatch/aw-client#162. The PR merged on November 7. The fix is in the repository’s default branch; the latest release (v0.5.9) predates it.
The reproduction uses synthetic events: one app, distinct titles, ten seconds per title, all marked active. It also includes a separate twenty-second event marked away, which must stay out of the total.
| Active titles | Correct total | Before repair | Proposed repair |
|---|---|---|---|
| 100 | 0:16:40 | 0:16:40 | 0:16:40 |
| 101 | 0:16:50 | 0:16:40 | 0:16:50 |
| 1,000 | 2:46:40 | 0:16:40 | 2:46:40 |
A report with 100 titles looks fine. The 101st is the useful test case.
The generated ActivityWatch query groups window events by app and title, sorts those groups by duration, and keeps at most 100. Alongside those groups, it computes a duration from the full filtered event set. These are two different results in the same response:
window.title_events at most 100 groups, for the title breakdown
window.duration duration of the full filtered event set
The report used title_events twice: first to print titles, then to add their durations for the total. The cap had silently become part of the arithmetic.
With unequal durations, the total would be the sum of the top 100 groups. It would not necessarily look suspicious. Increasing the cap would only move the point where the report becomes wrong.
The proposed replacement reads the scalar already in the response:
timedelta(seconds=period["window"]["duration"])
That keeps the existing query and its active-time filtering. There is no need to retrieve more title groups or issue a second query to count what the first one already counted. The query’s 100-group cap is also separate from the report’s --limit presentation option; changing that option cannot restore groups omitted from the query result.
The regression exercises the real report callback and generated query against an in-memory ActivityWatch datastore. Its eighteen cases combine 0, 1, 99, 100, 101 and 1,000 titles with --limit values of 1, 10 and 200. They check the printed total, the retained query cap, category assignment, exclusion of the away event and the single query call.
Replaying those tests against the original source produced six failures: both above-cap inputs at every display limit. The other twelve cases passed. Against the proposed repair, all eighteen passed. These are callback/query tests with substituted settings and transport, not a live-server or installed-package test.
There was a useful trap in the test harness too. The query interpreter returns a Python timedelta; the HTTP response represents that duration as seconds. An early reproduction passed the interpreter’s objects straight to the report. That was sufficient to expose the old undercount, but it gave the repaired code the wrong input type. The committed regression includes JSON conversion so the report receives the numeric duration it expects from HTTP.
For a report with a capped breakdown, one strong regression is to add an event that belongs in the total but falls outside the retained groups. Check that the total changes while the cap still holds. Otherwise a perfectly formatted table can quietly redefine what “total” means.