#!/usr/bin/env python3 """Generate a markdown working-hours report from .zed-hours/*.jsonl.""" import argparse import json import subprocess from collections import defaultdict from datetime import date, datetime, timedelta from pathlib import Path def parse_iso(ts: str) -> datetime: # Python < 3.11 compatibility for 'Z' suffix ts = ts.replace("Z", "+00:00") return datetime.fromisoformat(ts) def get_project_root(project_root: Path | None) -> Path: if project_root: return project_root.resolve() # Try git root try: return Path( subprocess.check_output( ["git", "rev-parse", "--show-toplevel"], text=True, stderr=subprocess.DEVNULL, ).strip() ).resolve() except (subprocess.CalledProcessError, FileNotFoundError): pass # Fallback: script location (/tools/time_tracking/generate_report.py) script_project = Path(__file__).resolve().parents[2] if (script_project / ".git").exists() or (script_project / ".zed-hours").exists(): return script_project # Final fallback: cwd return Path.cwd().resolve() def load_jsonl(log_file: Path) -> list[dict]: if not log_file.exists(): return [] records = [] with log_file.open("r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue try: records.append(json.loads(line)) except json.JSONDecodeError: continue return records def compute_daily_hours(heartbeats: list[dict], idle_threshold_seconds: int = 300) -> dict: """Compute active time per day from heartbeats. Logic: walk heartbeats in order. While Zed is active, accumulate time up to idle_threshold_seconds between heartbeats. If gap exceeds threshold, treat as idle break. """ by_date = defaultdict(list) for rec in heartbeats: try: dt = parse_iso(rec["timestamp"]) except Exception: continue by_date[dt.date()].append({"dt": dt, "active": rec.get("zed_active", False)}) daily_hours = {} for d, events in by_date.items(): events.sort(key=lambda x: x["dt"]) total_seconds = 0 last_active = None for ev in events: if ev["active"]: if last_active is None: last_active = ev["dt"] else: gap = (ev["dt"] - last_active).total_seconds() if gap <= idle_threshold_seconds: total_seconds += gap # Always advance last_active so long gaps don't accumulate twice last_active = ev["dt"] else: last_active = None daily_hours[d] = total_seconds / 3600 return daily_hours def commits_by_day(commits: list[dict]) -> dict: by_day = defaultdict(list) for c in commits: try: dt = parse_iso(c["timestamp"]) except Exception: continue by_day[dt.date()].append(c) return by_day def generate_report(project_root: Path, from_date: date, to_date: date) -> Path: log_dir = project_root / ".zed-hours" heartbeats = load_jsonl(log_dir / "heartbeats.jsonl") commits = load_jsonl(log_dir / "commits.jsonl") # Load config for idle threshold config_file = log_dir / "config.json" idle_threshold = 300 if config_file.exists(): try: config = json.loads(config_file.read_text(encoding="utf-8")) idle_threshold = int(config.get("idle_threshold_seconds", 300)) except Exception: pass daily_hours = compute_daily_hours(heartbeats, idle_threshold) commits_per_day = commits_by_day(commits) report_dir = log_dir / "reports" report_dir.mkdir(parents=True, exist_ok=True) report_path = report_dir / f"{from_date.isoformat()}_to_{to_date.isoformat()}.md" lines = [] lines.append(f"# Working Hours Report: {from_date} to {to_date}\n") lines.append(f"**Project:** `{project_root}`\n") lines.append(f"**Generated:** {date.today().isoformat()}\n") lines.append(f"**Sources:** `.zed-hours/heartbeats.jsonl`, `.zed-hours/commits.jsonl`\n") lines.append(f"**Idle threshold:** {idle_threshold} seconds\n\n") total_hours = 0.0 total_commits = 0 active_days = 0 lines.append("## Daily summary\n\n") lines.append("| Date | Heartbeats | Active hours | Commits | First heartbeat | Last heartbeat |\n") lines.append("|---|---:|---:|---:|---|---|\n") current = from_date while current <= to_date: events = [hb for hb in heartbeats if parse_iso(hb["timestamp"]).date() == current] day_commits = commits_per_day.get(current, []) hours = daily_hours.get(current, 0.0) if events: times = sorted(parse_iso(hb["timestamp"]) for hb in events) first = times[0].strftime("%H:%M:%S") last = times[-1].strftime("%H:%M:%S") active_days += 1 else: first = "—" last = "—" total_hours += hours total_commits += len(day_commits) lines.append( f"| {current.isoformat()} | {len(events)} | {hours:.2f} | {len(day_commits)} | {first} | {last} |\n" ) current += timedelta(days=1) lines.append("\n") lines.append("## Totals\n\n") lines.append(f"- **Active days:** {active_days}\n") lines.append(f"- **Total active hours (from heartbeats):** {total_hours:.2f}\n") lines.append(f"- **Total commits logged:** {total_commits}\n") lines.append(f"- **Period length:** {(to_date - from_date).days + 1} days\n") lines.append("\n") # Commits detail if commits: lines.append("## Commits in this period\n\n") lines.append("| Date | Time | Author | Subject |\n") lines.append("|---|---|---|---|\n") for c in sorted(commits, key=lambda x: parse_iso(x["timestamp"])): try: dt = parse_iso(c["timestamp"]) except Exception: continue if from_date <= dt.date() <= to_date: lines.append( f"| {dt.strftime('%Y-%m-%d')} | {dt.strftime('%H:%M:%S')} | {c.get('author', 'unknown')} | {c.get('subject', '')} |\n" ) lines.append("\n") lines.append("---\n\n") lines.append("*Generated by `tools/time_tracking/generate_report.py`.*\n") report_path.write_text("".join(lines), encoding="utf-8") return report_path def parse_date_arg(arg: str) -> date: if arg.lower() == "today": return date.today() if arg.lower() == "yesterday": return date.today() - timedelta(days=1) return date.fromisoformat(arg) def main() -> None: parser = argparse.ArgumentParser(description="Generate working hours report from Zed tracker logs.") parser.add_argument("--project-root", type=Path, default=None, help="Project root directory") parser.add_argument("--from", dest="from_date", type=str, default=None, help="Start date (YYYY-MM-DD, today, yesterday)") parser.add_argument("--to", dest="to_date", type=str, default=None, help="End date (YYYY-MM-DD, today, yesterday)") parser.add_argument("--today", action="store_true", help="Report for today only") parser.add_argument("--yesterday", action="store_true", help="Report for yesterday only") args = parser.parse_args() project_root = get_project_root(args.project_root) if args.today: from_date = to_date = date.today() elif args.yesterday: d = date.today() - timedelta(days=1) from_date = to_date = d else: if not args.from_date or not args.to_date: # Default to last 14 days to_date = date.today() from_date = to_date - timedelta(days=13) else: from_date = parse_date_arg(args.from_date) to_date = parse_date_arg(args.to_date) report_path = generate_report(project_root, from_date, to_date) print(f"Report written to: {report_path}") if __name__ == "__main__": main()