feat: report measured time, drop the nominal-day fit

Splitting a fixed 8h day by share turned 160 minutes of Friday morning
into "7h Tesser". The number looked measured and was not, and no column
in the table said which.

Cells now carry the time each project was actually active. The total
column is wall-clock presence — the union of active slots — so it reads
lower than the row sum when sessions overlapped. Measured time is a
floor; the skill says so and leaves adding the rest to the user.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
naps62
2026-08-14 20:33:34 +00:00
parent f0c301b3a3
commit 439d3601b4
4 changed files with 69 additions and 98 deletions
+36 -54
View File
@@ -14,11 +14,16 @@ transcripts on one project counts once, and two projects worked in parallel
each keep their own slots. Message counts would make one overnight autonomous
run outweigh a real morning; wall-clock presence does not.
Output is one line per project per day: suggested hours, share of the day, and
the active minutes behind it. The suggestion splits a nominal working day by
share, in 15-minute steps — active minutes are a floor on real work, never a
measure of it, so the day's length comes from the calendar and only the split
between projects comes from the sessions. See SKILL.md.
Output is a markdown table: one row per day, one column per project, each cell
holding the time that project was actually active. Nothing is extrapolated and
nothing is fitted to a nominal day — some days start early, some run late, and
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.
"""
import argparse
import collections
@@ -35,7 +40,6 @@ DEFAULTS = {
"slot_minutes": 5,
"day_start_hour": 8,
"day_end_hour": 20,
"nominal_day_hours": 8,
"projects": [],
"exclude": [],
}
@@ -110,35 +114,14 @@ def read_session(path):
return cwd, stamps
def split_day(entry, nominal_hours):
"""Split a nominal working day across the day's mapped projects by share.
Largest-remainder over 15-minute steps, so the parts add back up to the
whole day exactly. Unmapped paths get nothing: they are not known to be
client work, and silently handing them hours would hide a missing rule.
"""
for p in entry["projects"]:
p["suggested_hours"] = 0.0
entry["thin"] = entry["total_active_minutes"] < 30
mapped = [p for p in entry["projects"]
if not p["project"].startswith("unmapped:")]
weight = sum(p["active_minutes"] for p in mapped)
if not weight or entry["thin"]:
return
quarters = int(round(nominal_hours * 4))
exact = [(p, quarters * p["active_minutes"] / weight) for p in mapped]
given = [(p, int(v)) for p, v in exact]
left = quarters - sum(v for _, v in given)
order = sorted(range(len(exact)), key=lambda i: -(exact[i][1] % 1))
for i in order[:left]:
given[i] = (given[i][0], given[i][1] + 1)
for p, q in given:
p["suggested_hours"] = q / 4
def fmt_hours(h):
whole, rem = int(h), round((h - int(h)) * 60)
return f"{whole}h" if not rem else f"{whole}h{rem:02d}"
def fmt_dur(minutes):
"""45 -> '45min', 155 -> '2h35', 180 -> '3h', 0 -> ''."""
if not minutes:
return ""
if minutes < 60:
return f"{minutes}min"
h, m = divmod(minutes, 60)
return f"{h}h" if not m else f"{h}h{m:02d}"
def fmt_hour_list(hours):
@@ -234,12 +217,14 @@ def main():
while d <= until:
per = grid.get(d.isoformat(), {})
totals = {p: sum(len(s) for s in hrs.values()) for p, hrs in per.items()}
day_total = sum(totals.values())
# 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})
entry = {
"date": d.isoformat(),
"weekday": d.strftime("%a"),
"total_active_minutes": day_total * mins,
"nominal_day_hours": cfg["nominal_day_hours"],
"active_minutes": union * mins,
"projects": [],
}
for proj, n in sorted(totals.items(), key=lambda kv: -kv[1]):
@@ -247,7 +232,6 @@ def main():
entry["projects"].append({
"project": proj,
"active_minutes": n * mins,
"share": round(n / day_total, 3) if day_total else 0,
"first_hour": hrs[0],
"last_hour": hrs[-1],
"outside_workday": [
@@ -255,7 +239,6 @@ def main():
if h < cfg["day_start_hour"] or h >= cfg["day_end_hour"]
],
})
split_day(entry, cfg["nominal_day_hours"])
report.append(entry)
d += dt.timedelta(days=1)
@@ -266,14 +249,14 @@ def main():
if a.json:
json.dump({"since": since.isoformat(), "until": until.isoformat(),
"slot_minutes": mins, "sessions": nfiles,
"nominal_day_hours": cfg["nominal_day_hours"],
"unmapped": unmapped, "report": report}, sys.stdout, indent=1)
print()
return 0
print(f"{since} .. {until}{nfiles} sessions, {mins}-min slots, "
f"{cfg['timezone']}, {cfg['nominal_day_hours']}h day")
print("hours are a suggested split of a full day, not measured time\n")
f"{cfg['timezone']}")
print("measured active time, nothing extrapolated; "
"total is wall clock, so it can be less than the row\n")
cols = [p for p in dict.fromkeys(
x["project"] for e in report for x in e["projects"])
@@ -282,8 +265,9 @@ def main():
print("no mapped project activity in this range")
return 0
rows = [["day"] + cols + ["total", "flags"]]
rows = [["day"] + cols + ["total", "window", "flags"]]
week = collections.Counter()
week_total = 0
for e in report:
if not e["projects"]:
continue
@@ -291,26 +275,24 @@ def main():
cells = []
for c in cols:
p = by.get(c)
cells.append("" if not p or not p["suggested_hours"] else
f"{fmt_hours(p['suggested_hours'])} · "
f"{p['share']*100:.0f}% · {p['active_minutes']}min")
cells.append(fmt_dur(p["active_minutes"]) if p else "")
if p:
week[c] += p["suggested_hours"]
day_sum = sum(by[c]["suggested_hours"] for c in cols if c in by)
week[c] += p["active_minutes"]
week_total += e["active_minutes"]
hrs = [h for p in e["projects"] for h in (p["first_hour"], p["last_hour"])]
window = f"{min(hrs):02d}-{max(hrs):02d}h" if hrs else ""
flags = []
if e["thin"]:
flags.append("under 30min, treat as empty")
outside = sorted({h for p in e["projects"] for h in p["outside_workday"]})
if outside:
flags.append("outside " + fmt_hour_list(outside))
if any(p["project"].startswith("unmapped:") for p in e["projects"]):
flags.append("unmapped time excluded")
rows.append([f"{e['date']} {e['weekday']}"] + cells +
[fmt_hours(day_sum) if day_sum else "", "; ".join(flags)])
[fmt_dur(e["active_minutes"]), window, "; ".join(flags)])
rows.append(["**total**"] +
[f"**{fmt_hours(week[c])}**" for c in cols] +
[f"**{fmt_hours(sum(week.values()))}**", ""])
[f"**{fmt_dur(week[c])}**" for c in cols] +
[f"**{fmt_dur(week_total)}**", "", ""])
right = set(range(1, len(cols) + 2))
print(md_table(rows, align_right=right))