feat: emit the day report as a markdown table
Padded pipes, so it reads as a grid in the terminal and renders as a real table when pasted anywhere else. Each cell carries hours, share and active minutes; a flags column carries what needs judgement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+12
-12
@@ -43,24 +43,24 @@ missing step if not.
|
||||
python3 <skill-dir>/scripts/scan-activity.py --week last
|
||||
```
|
||||
|
||||
Prints one block per day — a line per project with suggested hours, share,
|
||||
active minutes, and the hour range — then a totals line. That output is the
|
||||
deliverable: show it as-is rather than restating it in prose.
|
||||
Prints a markdown table: one row per day, one column per project holding
|
||||
`hours · share · active minutes`, then a totals row. That table is the
|
||||
deliverable — paste it as-is rather than restating it in prose.
|
||||
|
||||
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.
|
||||
|
||||
Two things in the output need judgement, not arithmetic:
|
||||
The `flags` column carries the two things that need judgement, not arithmetic:
|
||||
|
||||
- **`outside` hours.** 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:` lines.** 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.
|
||||
- **`outside HHh`.** 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 time excluded`.** A path with no rule, left out of the split, so
|
||||
that day is 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.
|
||||
|
||||
`--json` carries the same fields if you need to compute against them.
|
||||
|
||||
@@ -77,7 +77,7 @@ job is the judgement it cannot do:
|
||||
|
||||
- One project at 85% or more: round it up to the whole day rather than leaving
|
||||
a token 1h on the other.
|
||||
- Days marked `under 30min, treat as empty` get nothing proposed.
|
||||
- Days flagged `under 30min, treat as empty` get nothing proposed.
|
||||
- Correct the split for the two things flagged in step 1 before proposing it.
|
||||
|
||||
Never scale a day *down* because its transcripts are thin. A quiet day is a
|
||||
|
||||
@@ -141,6 +141,33 @@ def fmt_hours(h):
|
||||
return f"{whole}h" if not rem else f"{whole}h{rem:02d}"
|
||||
|
||||
|
||||
def fmt_hour_list(hours):
|
||||
"""[0,7,20,21,22] -> '00h, 07h, 20-22h'."""
|
||||
out, run = [], []
|
||||
for h in sorted(set(hours)) + [None]:
|
||||
if run and h == run[-1] + 1:
|
||||
run.append(h)
|
||||
continue
|
||||
if run:
|
||||
out.append(f"{run[0]:02d}h" if len(run) == 1
|
||||
else f"{run[0]:02d}-{run[-1]:02d}h")
|
||||
run = [h] if h is not None else []
|
||||
return ", ".join(out)
|
||||
|
||||
|
||||
def md_table(rows, align_right=()):
|
||||
"""Render rows (first is the header) as a padded markdown table."""
|
||||
w = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))]
|
||||
def line(cells):
|
||||
return "| " + " | ".join(
|
||||
c.rjust(w[i]) if i in align_right else c.ljust(w[i])
|
||||
for i, c in enumerate(cells)) + " |"
|
||||
sep = "|" + "|".join(
|
||||
("-" * (w[i] + 1) + ":") if i in align_right else ("-" * (w[i] + 2))
|
||||
for i in range(len(w))) + "|"
|
||||
return "\n".join([line(rows[0]), sep] + [line(r) for r in rows[1:]])
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--since")
|
||||
@@ -244,50 +271,48 @@ def main():
|
||||
print()
|
||||
return 0
|
||||
|
||||
head = [
|
||||
f"{since} .. {until} ({nfiles} sessions, {mins}-min slots, "
|
||||
f"{cfg['timezone']}, {cfg['nominal_day_hours']}h day)",
|
||||
"hours are a suggested split of a full day, not measured time",
|
||||
]
|
||||
label_w = max([12] + [len(p["project"]) for e in report
|
||||
for p in e["projects"]])
|
||||
body = []
|
||||
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")
|
||||
|
||||
cols = [p for p in dict.fromkeys(
|
||||
x["project"] for e in report for x in e["projects"])
|
||||
if not p.startswith("unmapped:")]
|
||||
if not cols:
|
||||
print("no mapped project activity in this range")
|
||||
return 0
|
||||
|
||||
rows = [["day"] + cols + ["total", "flags"]]
|
||||
week = collections.Counter()
|
||||
for e in report:
|
||||
if not e["projects"]:
|
||||
continue
|
||||
if body:
|
||||
body.append("")
|
||||
note = " — under 30min, treat as empty" if e["thin"] else ""
|
||||
body.append(f"{e['date']} {e['weekday']} "
|
||||
f"{e['total_active_minutes']}min active{note}")
|
||||
for p in e["projects"]:
|
||||
out = ""
|
||||
if p["outside_workday"]:
|
||||
out = " outside " + ",".join(
|
||||
f"{h:02d}h" for h in p["outside_workday"])
|
||||
body.append(f" {p['project']:<{label_w}} "
|
||||
f"{fmt_hours(p['suggested_hours']):>6} "
|
||||
f"{p['share']*100:3.0f}% {p['active_minutes']:>4}min "
|
||||
f"{p['first_hour']:02d}-{p['last_hour']:02d}h{out}")
|
||||
by = {p["project"]: p for p in e["projects"]}
|
||||
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")
|
||||
if p:
|
||||
week[c] += p["suggested_hours"]
|
||||
day_sum = sum(by[c]["suggested_hours"] for c in cols if c in by)
|
||||
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)])
|
||||
|
||||
week = collections.Counter()
|
||||
for e in report:
|
||||
for p in e["projects"]:
|
||||
if p["suggested_hours"]:
|
||||
week[p["project"]] += p["suggested_hours"]
|
||||
total = ""
|
||||
if week:
|
||||
total = "total " + " ".join(
|
||||
f"{p} {fmt_hours(h)}" for p, h in week.most_common()) + \
|
||||
f" ({fmt_hours(sum(week.values()))})"
|
||||
|
||||
rule = "-" * max(len(x) for x in head + body + [total])
|
||||
print("\n".join(head))
|
||||
print(rule)
|
||||
print("\n".join(body))
|
||||
if total:
|
||||
print(rule)
|
||||
print(total)
|
||||
rows.append(["**total**"] +
|
||||
[f"**{fmt_hours(week[c])}**" for c in cols] +
|
||||
[f"**{fmt_hours(sum(week.values()))}**", ""])
|
||||
right = set(range(1, len(cols) + 2))
|
||||
print(md_table(rows, align_right=right))
|
||||
|
||||
if unmapped:
|
||||
print("\nunmapped paths — add them to the config or the exclude list:")
|
||||
|
||||
Reference in New Issue
Block a user