refactor: hourlog prints a day-by-project table

One row per day, one column per project, hours in the cell. The per-day
blocks repeated the project name on every line and buried the totals.

Share, active minutes, hour ranges, and the outside-working-hours flags
move to --json, which is where the skill reads them anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
naps62
2026-08-14 18:38:45 +00:00
parent 95e98a1e77
commit cd63a4c18e
2 changed files with 59 additions and 43 deletions
+26 -22
View File
@@ -43,20 +43,25 @@ missing step if not.
python3 <skill-dir>/scripts/scan-activity.py --week last python3 <skill-dir>/scripts/scan-activity.py --week last
``` ```
Reports one line per project per day: suggested hours, share of the day, active Prints a table: one row per day, one column per project, hours in each cell.
minutes, and the hour range they fell in. The unit is a 5-minute slot in which That table is the deliverable — show it as-is rather than restating it.
at least one message was written, deduplicated per project — so a 40-subagent
swarm on one project counts once, and two projects worked in parallel each keep
their own slots. Message counts would let one overnight autonomous run outweigh
a real morning.
Two things in the output need judgement, not arithmetic: The unit behind it is a 5-minute slot in which at least one message was
written, deduplicated per project — so a 40-subagent swarm on one project
counts once, and two projects worked in parallel each keep their own slots.
Message counts would let one overnight autonomous run outweigh a real morning.
- **`outside working hours` lines.** Activity at 02:00 is usually an unattended Add `--json` for the evidence behind each cell: active minutes, share, hour
run, not work. Discount it unless the user says otherwise. range, and which hours fell outside working hours. Read it before proposing
- **`unmapped:` lines.** A path with no rule. Either it is a new client anything, and note two things there:
directory the config is missing — say so and ask — or it is personal work
that belongs in `exclude`. Never guess it into a client project. - **Hours flagged `outside_workday`.** Activity at 02:00 is usually an
unattended run, not work. If most of a project's minutes came from 21h-02h,
its share is inflated — say so and shift the split.
- **`unmapped` entries.** A path with no rule, excluded from the split, so the
affected days are wrong rather than merely incomplete. Either it is a new
client directory the config is missing — say so and ask — or it is personal
work that belongs in `exclude`. Never guess it into a client project.
## 2. Propose the hours ## 2. Propose the hours
@@ -71,13 +76,8 @@ job is the judgement it cannot do:
- One project at 85% or more: round it up to the whole day rather than leaving - One project at 85% or more: round it up to the whole day rather than leaving
a token 1h on the other. a token 1h on the other.
- Discount hours flagged `outside`. If most of a project's minutes came from - Days marked `*` in the table get nothing proposed.
21h-02h, its share is inflated by an unattended run — say so and shift the - Correct the split for the two things flagged in step 1 before proposing it.
split rather than passing the number through.
- Days marked `under 30min, treat as empty` get nothing proposed.
- An `unmapped:` line with real minutes means the split is wrong, not just
incomplete: those minutes were excluded from the division. Resolve the
mapping before proposing hours for that day.
Never scale a day *down* because its transcripts are thin. A quiet day is a Never scale a day *down* because its transcripts are thin. A quiet day is a
normal working day unless the user says it was not, or the timesheet already normal working day unless the user says it was not, or the timesheet already
@@ -100,9 +100,13 @@ planned hours) or absent. That splits the proposal in two:
## 4. Show the table, then ask ## 4. Show the table, then ask
One row per project per day: date, weekday, project, proposed hours, what the The scan's table, one row per day and one column per project, with the
timesheet currently says, and the action (confirm / adjust / add / skip). timesheet's current hours beside each proposed cell where the two differ. Mark
Flag every row where the evidence was weak or outside working hours. any day that needs a new entry rather than a confirmation.
Keep it to that table plus a line for anything you had to judge — a discounted
overnight run, an unmapped path, a day you left empty. No commentary on days
that were straightforward.
Then ask once, plainly, whether to submit. Wait for an answer. Silence, a Then ask once, plainly, whether to submit. Wait for an answer. Silence, a
timeout, or "user may be away" is not approval — leave the timesheet alone and timeout, or "user may be away" is not approval — leave the timesheet alone and
+33 -21
View File
@@ -242,29 +242,41 @@ def main():
print() print()
return 0 return 0
print(f"{since} .. {until} ({nfiles} sessions, {mins}-min slots, " cols = [p for p in dict.fromkeys(
f"{cfg['timezone']}, {cfg['nominal_day_hours']}h day)") x["project"] for e in report for x in e["projects"])
print("hours are a suggested split of a full day, not measured time\n") if not p.startswith("unmapped:")]
label_w = max([12] + [len(p["project"]) for e in report widths = [max(len(c), 6) for c in cols]
for p in e["projects"]]) head = "day " + " ".join(
f"{c:>{w}}" for c, w in zip(cols, widths)) + " total"
print(head)
print("-" * len(head))
totals = collections.Counter()
flagged = False
for e in report: for e in report:
if not e["projects"]: hours = {p["project"]: p["suggested_hours"] for p in e["projects"]}
continue day_sum = sum(hours.get(c, 0) for c in cols)
note = " — under 30min, treat as empty" if e["thin"] else "" cells = " ".join(
print(f"{e['date']} {e['weekday']} " f"{fmt_hours(hours[c]) if hours.get(c) else '':>{w}}"
f"{e['total_active_minutes']}min active{note}") for c, w in zip(cols, widths))
for p in e["projects"]: mark = " *" if e["thin"] and e["total_active_minutes"] else ""
out = "" flagged = flagged or bool(mark)
if p["outside_workday"]: print(f"{e['date']} {e['weekday']} {cells} "
out = " outside " + ",".join( f"{fmt_hours(day_sum) if day_sum else '':>5}{mark}")
f"{h:02d}h" for h in p["outside_workday"]) for c in cols:
print(f" {p['project']:<{label_w}} " totals[c] += hours.get(c, 0)
f"{fmt_hours(p['suggested_hours']):>6} "
f"{p['share']*100:3.0f}% {p['active_minutes']:>4}min " print("-" * len(head))
f"{p['first_hour']:02d}-{p['last_hour']:02d}h{out}") print("total " + " ".join(
print() f"{fmt_hours(totals[c]):>{w}}" for c, w in zip(cols, widths)) +
f" {fmt_hours(sum(totals.values())):>5}")
if flagged:
print("\n* under 30min of activity — treat as empty unless you know "
"otherwise")
if unmapped: if unmapped:
print("unmapped paths — add them to the config or the exclude list:") print("\nunmapped paths, excluded from the split — add them to the "
"config or the exclude list:")
for u in unmapped: for u in unmapped:
print(" ", u) print(" ", u)
return 0 return 0