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:
2026-09-11 10:27:56 +02:00
commit bfff093dc6
5 changed files with 943 additions and 0 deletions
+232
View File
@@ -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()