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:
@@ -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.
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
Reference in New Issue
Block a user