Add Zed Hours Tracker tooling
Introduces a local-first time tracker for Zed editor sessions, including background activity tracking, automatic git commit logging, and markdown report generation. Includes install script, systemd service template, and setup instructions in README.
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
#!/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 (<project>/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()
|
||||
Reference in New Issue
Block a user