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
+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,
so the user adds those back; the script never does.
The 'total' column is wall-clock presence, the union of active slots across
projects, so on days with parallel sessions it is less than the row sum.
Overlap is divided, not double-counted: five minutes with two projects open is
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 collections
@@ -114,6 +115,21 @@ def read_session(path):
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):
"""45 -> '45min', 155 -> '2h35', 180 -> '3h', 0 -> ''."""
if not minutes:
@@ -216,22 +232,27 @@ def main():
d = since
while d <= until:
per = grid.get(d.isoformat(), {})
totals = {p: sum(len(s) for s in hrs.values()) for p, hrs in per.items()}
# Wall clock, not the row sum: a slot with two projects live is one
# minute of the user's day, counted once here and once per project.
union = len({s for hrs in per.values() for ss in hrs.values()
for s in ss})
slots_of = {p: {s for ss in hrs.values() for s in ss}
for p, hrs in per.items()
if not p.startswith("unmapped:")}
owners = collections.Counter()
for ss in slots_of.values():
owners.update(ss)
share = attribute(slots_of, owners, mins)
entry = {
"date": d.isoformat(),
"weekday": d.strftime("%a"),
"active_minutes": union * mins,
"active_minutes": len(owners) * mins,
"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])
entry["projects"].append({
"project": proj,
"active_minutes": n * mins,
"active_minutes": share.get(proj, raw[proj]),
"raw_minutes": raw[proj],
"first_hour": hrs[0],
"last_hour": hrs[-1],
"outside_workday": [
@@ -256,7 +277,7 @@ def main():
print(f"{since} .. {until}{nfiles} sessions, {mins}-min slots, "
f"{cfg['timezone']}")
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(
x["project"] for e in report for x in e["projects"])