feat: split overlapping minutes between projects

Five minutes with two projects open was counted as five for each, so
Wednesday's columns summed to 18h against 13h10 of wall clock and no
column said which number to trust.

Each slot's minutes are now divided evenly among the projects live in
it, largest remainder over whole minutes, so the columns add up to the
total exactly. That ratio is what a submission scales the user's stated
day length by.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
naps62
2026-08-14 22:05:13 +00:00
parent 439d3601b4
commit b485cee634
2 changed files with 39 additions and 13 deletions
+7 -2
View File
@@ -48,8 +48,13 @@ time that project was actually active, plus wall-clock `total`, the day's
`window`, and `flags`. That table is the deliverable — paste it as-is rather `window`, and `flags`. That table is the deliverable — paste it as-is rather
than restating it in prose. than restating it in prose.
`total` is the union of active slots, so on a day with parallel sessions it is Overlap is divided, not double-counted: five minutes with two projects open is
less than the row sum. Both numbers are true; they answer different questions. five minutes of the day, half to each. The project columns therefore add up to
`total`, which is wall-clock presence.
That division is also the ratio to submit with. The user gives the day's real
total; you split it by these proportions. Never submit the measured numbers as
the hours unless the user says they are right.
Paste it as plain markdown in the reply. Never wrap it in a code fence: a Paste it as plain markdown in the reply. Never wrap it in a code fence: a
fence shows the raw pipes and dashes instead of a rendered table. fence shows the raw pipes and dashes instead of a rendered table.
+32 -11
View File
@@ -22,8 +22,9 @@ a guessed number is worse than a small true one.
Measured time is a floor. Meetings, review and thinking leave no transcript, Measured time is a floor. Meetings, review and thinking leave no transcript,
so the user adds those back; the script never does. so the user adds those back; the script never does.
The 'total' column is wall-clock presence, the union of active slots across Overlap is divided, not double-counted: five minutes with two projects open is
projects, so on days with parallel sessions it is less than the row sum. five minutes of the day, half to each. So the project columns add up to the
'total' column, which is wall-clock presence.
""" """
import argparse import argparse
import collections import collections
@@ -114,6 +115,21 @@ def read_session(path):
return cwd, stamps return cwd, stamps
def attribute(slots_of, owners, mins):
"""Divide each slot's minutes evenly among the projects live in it.
Five minutes with two projects open is five minutes of the user's day, not
ten, so each project gets half. Largest remainder over whole minutes keeps
the parts summing to wall clock exactly.
"""
exact = {p: sum(mins / owners[s] for s in ss) for p, ss in slots_of.items()}
floors = {p: int(v) for p, v in exact.items()}
left = round(sum(exact.values())) - sum(floors.values())
for p in sorted(exact, key=lambda p: -(exact[p] - floors[p]))[:left]:
floors[p] += 1
return floors
def fmt_dur(minutes): def fmt_dur(minutes):
"""45 -> '45min', 155 -> '2h35', 180 -> '3h', 0 -> ''.""" """45 -> '45min', 155 -> '2h35', 180 -> '3h', 0 -> ''."""
if not minutes: if not minutes:
@@ -216,22 +232,27 @@ def main():
d = since d = since
while d <= until: while d <= until:
per = grid.get(d.isoformat(), {}) per = grid.get(d.isoformat(), {})
totals = {p: sum(len(s) for s in hrs.values()) for p, hrs in per.items()} slots_of = {p: {s for ss in hrs.values() for s in ss}
# Wall clock, not the row sum: a slot with two projects live is one for p, hrs in per.items()
# minute of the user's day, counted once here and once per project. if not p.startswith("unmapped:")}
union = len({s for hrs in per.values() for ss in hrs.values() owners = collections.Counter()
for s in ss}) for ss in slots_of.values():
owners.update(ss)
share = attribute(slots_of, owners, mins)
entry = { entry = {
"date": d.isoformat(), "date": d.isoformat(),
"weekday": d.strftime("%a"), "weekday": d.strftime("%a"),
"active_minutes": union * mins, "active_minutes": len(owners) * mins,
"projects": [], "projects": [],
} }
for proj, n in sorted(totals.items(), key=lambda kv: -kv[1]): raw = {p: sum(len(s) for s in hrs.values()) * mins
for p, hrs in per.items()}
for proj in sorted(per, key=lambda p: -raw[p]):
hrs = sorted(per[proj]) hrs = sorted(per[proj])
entry["projects"].append({ entry["projects"].append({
"project": proj, "project": proj,
"active_minutes": n * mins, "active_minutes": share.get(proj, raw[proj]),
"raw_minutes": raw[proj],
"first_hour": hrs[0], "first_hour": hrs[0],
"last_hour": hrs[-1], "last_hour": hrs[-1],
"outside_workday": [ "outside_workday": [
@@ -256,7 +277,7 @@ def main():
print(f"{since} .. {until}{nfiles} sessions, {mins}-min slots, " print(f"{since} .. {until}{nfiles} sessions, {mins}-min slots, "
f"{cfg['timezone']}") f"{cfg['timezone']}")
print("measured active time, nothing extrapolated; " print("measured active time, nothing extrapolated; "
"total is wall clock, so it can be less than the row\n") "overlapping minutes split evenly between projects\n")
cols = [p for p in dict.fromkeys( cols = [p for p in dict.fromkeys(
x["project"] for e in report for x in e["projects"]) x["project"] for e in report for x in e["projects"])