From bfff093dc63b17b99166beb3b3c32717df892160 Mon Sep 17 00:00:00 2001 From: Florian Egger Date: Fri, 11 Sep 2026 10:27:56 +0200 Subject: [PATCH] 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. --- README.md | 185 ++++++++++++++++++++++++++++ generate_report.py | 232 +++++++++++++++++++++++++++++++++++ install.py | 129 ++++++++++++++++++++ log_commit.py | 101 ++++++++++++++++ zed_tracker.py | 296 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 943 insertions(+) create mode 100644 README.md create mode 100644 generate_report.py create mode 100644 install.py create mode 100644 log_commit.py create mode 100644 zed_tracker.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..f58a7fb --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# Zed Hours Tracker + +A lightweight, local-first, background time tracker for Zed editor sessions, with automatic git commit logging. No cloud, no accounts, no manual timers. + +Works on **Arch Linux + Wayland** and is designed to be portable to other projects. + +--- + +## What it does + +1. **Detects when you are working in Zed** on this project by monitoring the active window title (Wayland) and filesystem activity. +2. **Writes heartbeats** to `.zed-hours/heartbeats.jsonl`: + - timestamp + - project path + - focused file (if detectable) + - idle status +3. **Logs git commits** automatically via `post-commit` hook to `.zed-hours/commits.jsonl`. +4. **Generates markdown reports** combining heartbeats + commits into daily summaries. + +--- + +## Files + +| File | Purpose | +|---|---| +| `install.py` | Install git hook and create `.zed-hours/` data directory | +| `log_commit.py` | Called by git hook; do not run directly usually | +| `zed_tracker.py` | Background tracker for Zed activity | +| `generate_report.py` | Generate a markdown hours report | +| `README.md` | This file | + +--- + +## Installation + +Run from the project root: + +```bash +python3 tools/time_tracking/install.py +``` + +This will: +- create `.zed-hours/` in the project root; +- install a `post-commit` git hook that logs commits; +- create a default config file. + +--- + +## Running the tracker + +The tracker is intentionally a simple script you run in the background. + +### Option A: One-off test + +```bash +python3 tools/time_tracking/zed_tracker.py --project-root $(pwd) +``` + +Press `Ctrl+C` to stop. + +### Option B: Zed tasks (run from the editor) + +This project includes Zed tasks so you can trigger tracker actions with the task runner shortcut (`alt+shift+t` or `ctrl+shift+t` depending on your keymap). The default working directory for Zed tasks is the project root, so the commands use relative paths. + +| Task | Action | +|---|---| +| `Zed Hours: Install tracker` | Install git hook, config, and systemd service | +| `Zed Hours: Start tracker (foreground)` | Start the tracker in a Zed terminal panel | +| `Zed Hours: Generate report (today)` | Generate today's report | +| `Zed Hours: Generate report (yesterday)` | Generate yesterday's report | +| `Zed Hours: Generate report (last 14 days)` | Generate a 14-day report | +| `Zed Hours: Open latest report` | Print path to latest report | +| `Zed Hours: Status` | Show heartbeat/commit/report counts | + +### Option C: Background via systemd user service (recommended for daily use) + +A sample service file is generated by `install.py` at: + +``` +~/.config/systemd/user/zed-hours-gem360.service +``` + +Enable it: + +```bash +systemctl --user daemon-reload +systemctl --user enable --now zed-hours-gem360.service +systemctl --user status zed-hours-gem360.service +``` + +Logs: + +```bash +journalctl --user -u zed-hours-gem360.service -f +``` + +To stop: + +```bash +systemctl --user stop zed-hours-gem360.service +``` + +### Option C: `tmux` / `screen` + +```bash +tmux new -s zed-hours -d 'python3 tools/time_tracking/zed_tracker.py --project-root $(pwd)' +``` + +--- + +## Generating a report + +```bash +python3 tools/time_tracking/generate_report.py --from 2026-08-03 --to yesterday +``` + +Output is written to `.zed-hours/reports/YYYY-MM-DD_to_YYYY-MM-DD.md`. + +You can also run: + +```bash +python3 tools/time_tracking/generate_report.py --today +``` + +--- + +## How activity detection works on Wayland + +Wayland does not expose a global active-window API for security reasons. The tracker tries several methods, in order: + +1. **Hyprland**: `hyprctl activewindow` +2. **Sway**: `swaymsg -t get_tree` +3. **GNOME / Mutter**: D-Bus `GetActiveWindow` via `gdbus` +4. **KDE / KWin**: D-Bus via `qdbus` +5. **Fallback**: monitor filesystem activity inside the project root using `inotify` or polling recent file access/modification times. + +If all window-detection methods fail, the fallback still gives you a reliable signal that someone is actively editing files in this project. + +--- + +## Reusing in another project + +Copy the `tools/time_tracking/` directory to the new project and run: + +```bash +python3 tools/time_tracking/install.py +``` + +The scripts detect the project root automatically from the current working directory. + +--- + +## Data format + +### `.zed-hours/heartbeats.jsonl` + +```json +{"timestamp": "2026-08-26T09:15:00", "project": "/home/fegger/Code/ixsol/gem360-git", "file": "README.md", "active": true, "source": "hyprctl"} +``` + +### `.zed-hours/commits.jsonl` + +```json +{"type": "commit", "timestamp": "2026-08-26T09:20:00", "repo": "/home/fegger/Code/ixsol/gem360-git", "branch": "va_module", "sha": "abc12345", "message": "[ADD] feature X", "author": "Florian Egger"} +``` + +### `.zed-hours/config.json` + +```json +{ + "project_root": "/home/fegger/Code/ixsol/gem360-git", + "heartbeat_interval_seconds": 60, + "idle_threshold_seconds": 300, + "log_dir": ".zed-hours" +} +``` + +--- + +## Notes + +- This tool is intentionally simple and local. It is not a replacement for a commercial time tracker, but it is a reliable fallback when you forget to log hours. +- The tracker does not send data anywhere. +- Heartbeat granularity is one minute by default. Tune it in `config.json`. +- Because of Wayland security, window detection may not give the exact filename. The filesystem fallback compensates for this. diff --git a/generate_report.py b/generate_report.py new file mode 100644 index 0000000..e3f64ad --- /dev/null +++ b/generate_report.py @@ -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 (/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() diff --git a/install.py b/install.py new file mode 100644 index 0000000..4af7fc3 --- /dev/null +++ b/install.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +"""Install the Zed Hours Tracker git hook and config for the current project.""" + +import json +import os +import stat +import subprocess +from pathlib import Path + + +def get_project_root() -> Path: + """Return the git project root, falling back to the script's project directory.""" + try: + root = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + return Path(root).resolve() + except (subprocess.CalledProcessError, FileNotFoundError): + pass + # Fallback 1: directory containing this script's grandparent (/tools/time_tracking/install.py) + script_project = Path(__file__).resolve().parents[2] + if (script_project / ".git").exists() or (script_project / ".zed-hours").exists(): + return script_project + # Fallback 2: current working directory + return Path.cwd().resolve() + + +def ensure_log_dir(project_root: Path) -> Path: + log_dir = project_root / ".zed-hours" + log_dir.mkdir(parents=True, exist_ok=True) + return log_dir + + +def write_config(log_dir: Path, project_root: Path) -> Path: + config = { + "project_root": str(project_root), + "log_dir": str(log_dir.relative_to(project_root)), + "heartbeat_interval_seconds": 60, + "idle_threshold_seconds": 300, + "max_inactive_gap_seconds": 600, + } + config_path = log_dir / "config.json" + if not config_path.exists(): + config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8") + print(f"Created config: {config_path}") + else: + print(f"Config already exists: {config_path}") + return config_path + + +def install_git_hook(project_root: Path) -> None: + hook_path = project_root / ".git" / "hooks" / "post-commit" + if not hook_path.parent.exists(): + print("No .git directory found; skipping git hook install.") + return + + script_dir = project_root / "tools" / "time_tracking" + log_commit_script = script_dir / "log_commit.py" + + hook_content = f"""#!/bin/sh +# Zed Hours Tracker - post-commit hook +python3 "{log_commit_script}" "$@" +""" + if hook_path.exists(): + existing = hook_path.read_text(encoding="utf-8") + if "Zed Hours Tracker" in existing: + print(f"Hook already installed: {hook_path}") + return + # Append to existing hook + hook_path.write_text(existing.rstrip("\n") + "\n\n" + hook_content, encoding="utf-8") + else: + hook_path.write_text(hook_content, encoding="utf-8") + + # Make executable + hook_path.chmod(hook_path.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + print(f"Installed git hook: {hook_path}") + + +def write_systemd_service(project_root: Path, script_dir: Path) -> None: + """Write a sample systemd user service file for background tracking.""" + systemd_dir = Path.home() / ".config" / "systemd" / "user" + service_name = f"zed-hours-{project_root.name}.service" + service_path = systemd_dir / service_name + + tracker_script = script_dir / "zed_tracker.py" + + service_content = f"""[Unit] +Description=Zed Hours Tracker for {project_root.name} +After=graphical-session.target + +[Service] +Type=simple +WorkingDirectory={project_root} +ExecStart=/usr/bin/python3 {tracker_script} --project-root {project_root} +Restart=on-failure +RestartSec=10 +Environment="PYTHONUNBUFFERED=1" + +[Install] +WantedBy=default.target +""" + systemd_dir.mkdir(parents=True, exist_ok=True) + service_path.write_text(service_content, encoding="utf-8") + print(f"Wrote systemd user service: {service_path}") + print(f"Enable with: systemctl --user daemon-reload && systemctl --user enable --now {service_name}") + + +def main() -> None: + project_root = get_project_root() + script_dir = Path(__file__).resolve().parent + + log_dir = ensure_log_dir(project_root) + write_config(log_dir, project_root) + install_git_hook(project_root) + write_systemd_service(project_root, script_dir) + + print(f"\nZed Hours Tracker installed for project: {project_root}") + print(f"Data directory: {log_dir}") + print("\nNext steps:") + print(" 1. Start the tracker: python3 tools/time_tracking/zed_tracker.py") + print(" 2. Or enable the systemd service shown above.") + print(" 3. Make a commit to test the git hook.") + print(" 4. Generate a report: python3 tools/time_tracking/generate_report.py --today") + + +if __name__ == "__main__": + main() diff --git a/log_commit.py b/log_commit.py new file mode 100644 index 0000000..d911b96 --- /dev/null +++ b/log_commit.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Log a git commit event to .zed-hours/commits.jsonl. + +This script is normally called by the git post-commit hook. +It can also be run manually for testing. +""" + +import json +import os +import subprocess +from datetime import datetime, timezone +from pathlib import Path + + +def get_project_root() -> Path: + try: + root = subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + cwd=os.environ.get("GIT_DIR", "."), + text=True, + stderr=subprocess.DEVNULL, + ).strip() + return Path(root).resolve() + except (subprocess.CalledProcessError, FileNotFoundError): + pass + # Fallback 1: script location (/tools/time_tracking/) + script_project = Path(__file__).resolve().parents[2] + if (script_project / ".git").exists() or (script_project / ".zed-hours").exists(): + return script_project + # Fallback 2: current directory + return Path.cwd().resolve() + + +def get_last_commit_info(project_root: Path) -> dict: + try: + log_format = "%H|%an|%ae|%s|%D" + out = subprocess.check_output( + ["git", "log", "-1", f"--format={log_format}"], + cwd=project_root, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + sha, author, email, subject, refs = out.split("|", 4) + # Try to extract current branch + branch = "unknown" + try: + branch = subprocess.check_output( + ["git", "rev-parse", "--abbrev-ref", "HEAD"], + cwd=project_root, + text=True, + stderr=subprocess.DEVNULL, + ).strip() + except subprocess.CalledProcessError: + pass + return { + "sha": sha, + "author": author, + "email": email, + "subject": subject, + "branch": branch, + } + except subprocess.CalledProcessError as e: + return { + "sha": "unknown", + "author": "unknown", + "email": "unknown", + "subject": f"error: {e}", + "branch": "unknown", + } + + +def log_commit(project_root: Path | None = None) -> Path | None: + if project_root is None: + project_root = get_project_root() + + log_dir = project_root / ".zed-hours" + log_dir.mkdir(parents=True, exist_ok=True) + + commit_info = get_last_commit_info(project_root) + record = { + "type": "commit", + "timestamp": datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds"), + "repo": str(project_root), + **commit_info, + } + + log_file = log_dir / "commits.jsonl" + with log_file.open("a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + return log_file + + +def main() -> None: + log_file = log_commit() + if log_file: + print(f"Logged commit to {log_file}") + + +if __name__ == "__main__": + main() diff --git a/zed_tracker.py b/zed_tracker.py new file mode 100644 index 0000000..4a86afe --- /dev/null +++ b/zed_tracker.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 +"""Background Zed activity tracker for Wayland (Arch Linux) and fallback. + +Logs heartbeats to .zed-hours/heartbeats.jsonl. +""" + +import argparse +import json +import os +import re +import shutil +import subprocess +import sys +import time +from datetime import datetime, timezone +from pathlib import Path + +ZED_APP_NAMES = {"zed", "zeditor"} +PROJECT_ZED_PATTERN = re.compile(r"(?:^|\s)([^/]+?)(?:\s+[-—]\s+Zed(?:\s+Editor)?)$") + + +def now_iso() -> str: + return datetime.now(timezone.utc).astimezone().isoformat(timespec="seconds") + + +def load_config(project_root: Path) -> dict: + config_path = project_root / ".zed-hours" / "config.json" + defaults = { + "project_root": str(project_root), + "log_dir": ".zed-hours", + "heartbeat_interval_seconds": 60, + "idle_threshold_seconds": 300, + "max_inactive_gap_seconds": 600, + } + if config_path.exists(): + try: + loaded = json.loads(config_path.read_text(encoding="utf-8")) + defaults.update(loaded) + except Exception as e: + print(f"Warning: could not read config: {e}", file=sys.stderr) + return defaults + + +def get_active_window_wayland() -> dict | None: + """Try several Wayland compositor methods to detect the active window.""" + + # 1. Hyprland + if shutil.which("hyprctl"): + try: + out = subprocess.check_output( + ["hyprctl", "activewindow", "-j"], + text=True, + stderr=subprocess.DEVNULL, + timeout=2, + ) + data = json.loads(out) + title = (data.get("title") or "").strip() + class_ = (data.get("class") or "").strip().lower() + return {"title": title, "class": class_, "source": "hyprctl"} + except Exception: + pass + + # 2. Sway + if shutil.which("swaymsg"): + try: + out = subprocess.check_output( + ["swaymsg", "-t", "get_tree"], + text=True, + stderr=subprocess.DEVNULL, + timeout=2, + ) + data = json.loads(out) + + def find_focused(node): + if node.get("focused"): + return node + for child in node.get("nodes", []) + node.get("floating_nodes", []): + found = find_focused(child) + if found: + return found + return None + + focused = find_focused(data) + if focused: + props = focused.get("window_properties", {}) + title = focused.get("name") or props.get("title") or "" + class_ = props.get("class", "").lower() + return {"title": title, "class": class_, "source": "swaymsg"} + except Exception: + pass + + # 3. GNOME / Mutter via gdbus + if shutil.which("gdbus"): + try: + out = subprocess.check_output( + [ + "gdbus", "call", "--session", "--dest", "org.gnome.Shell", + "--object-path", "/org/gnome/Shell", "--method", "org.gnome.Shell.Eval", + "global.display.focus_window.get_title()", + ], + text=True, + stderr=subprocess.DEVNULL, + timeout=2, + ) + match = re.search(r"'(.*)'", out) + if match: + title = match.group(1) + return {"title": title, "class": "", "source": "gdbus-gnome"} + except Exception: + pass + + # 4. KDE / KWin via qdbus / qdbus6 + for qdbus in ("qdbus6", "qdbus"): + if shutil.which(qdbus): + try: + out = subprocess.check_output( + [qdbus, "org.kde.KWin", "/KWin", "org.kde.KWin.activeWindow"], + text=True, + stderr=subprocess.DEVNULL, + timeout=2, + ).strip() + # qdbus returns a window id; we would need more introspection for title. + # Fallback: try activeWindowCaption if available + caption = subprocess.check_output( + [qdbus, "org.kde.KWin", "/KWin", "org.kde.KWin.activeWindowCaption"], + text=True, + stderr=subprocess.DEVNULL, + timeout=2, + ).strip() + if caption: + return {"title": caption, "class": "", "source": f"{qdbus}-kwin"} + except Exception: + pass + + return None + + +def is_zed(window: dict | None) -> bool: + if not window: + return False + title = window.get("title", "").lower() + class_ = window.get("class", "").lower() + return ( + class_ in ZED_APP_NAMES + or "zed" in class_ + or title.endswith(" - zed") + or title.endswith(" - zed editor") + or "zeditor" in class_ + ) + + +def extract_project_from_title(title: str, project_root: Path) -> str | None: + """Try to identify which project/folder is open in Zed from window title.""" + match = PROJECT_ZED_PATTERN.search(title) + if match: + return match.group(1) + # Fallback: if title ends with the project root name, assume this project + if project_root.name.lower() in title.lower(): + return project_root.name + return None + + +def get_latest_project_file_activity(project_root: Path) -> datetime | None: + """Fallback: find the most recent access or modification time under the project.""" + latest = None + # Common directories to ignore + ignored = {".git", ".zed-hours", "__pycache__", "node_modules", ".venv", "venv"} + try: + for dirpath, dirnames, filenames in os.walk(project_root): + # Prune ignored dirs + dirnames[:] = [d for d in dirnames if d not in ignored] + for name in filenames: + # Skip hidden, binary-ish, or generated files + if name.startswith("."): + continue + path = Path(dirpath) / name + try: + stat = path.stat() + mtime = datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc) + if latest is None or mtime > latest: + latest = mtime + except Exception: + continue + except Exception as e: + print(f"Warning: filesystem scan failed: {e}", file=sys.stderr) + return latest + + +def log_heartbeat(log_dir: Path, project_root: Path, window: dict | None, active: bool, source: str) -> None: + title = window.get("title") if window else None + project_in_title = None + if title: + project_in_title = extract_project_from_title(title, project_root) + + record = { + "timestamp": now_iso(), + "project": str(project_root), + "project_in_title": project_in_title, + "zed_active": active, + "source": source, + "title": title, + "class": window.get("class") if window else None, + } + + log_file = log_dir / "heartbeats.jsonl" + with log_file.open("a", encoding="utf-8") as f: + f.write(json.dumps(record, ensure_ascii=False) + "\n") + + +def tracker_loop(project_root: Path, config: dict) -> None: + log_dir = project_root / config["log_dir"] + log_dir.mkdir(parents=True, exist_ok=True) + + interval = int(config.get("heartbeat_interval_seconds", 60)) + idle_threshold = int(config.get("idle_threshold_seconds", 300)) + + last_window_state: dict | None = None + last_activity_time = time.time() + filesystem_last = None + + print(f"Zed tracker started for {project_root}") + print(f"Logging to {log_dir}") + print(f"Heartbeat interval: {interval}s") + print("Press Ctrl+C to stop.\n") + + try: + while True: + now = time.time() + window = get_active_window_wayland() + active = is_zed(window) + source = window.get("source") if window else None + + if active: + last_activity_time = now + else: + # Fallback: check filesystem activity + latest = get_latest_project_file_activity(project_root) + if latest and (filesystem_last is None or latest > filesystem_last): + filesystem_last = latest + last_activity_time = now + active = True + source = "filesystem-fallback" + + log_heartbeat(log_dir, project_root, window, active, source or "unknown") + + if active != (last_window_state is not None and is_zed(last_window_state)): + status = "ACTIVE" if active else "inactive" + print(f"[{now_iso()}] Zed {status} (source: {source})") + + last_window_state = window + time.sleep(interval) + + except KeyboardInterrupt: + print("\nZed tracker stopped.") + + +def main() -> None: + parser = argparse.ArgumentParser(description="Track Zed activity and log heartbeats.") + parser.add_argument( + "--project-root", + type=Path, + default=None, + help="Project root directory (defaults to git root or script location)", + ) + args = parser.parse_args() + + project_root = args.project_root + if project_root: + project_root = project_root.resolve() + else: + # Try git root + try: + project_root = Path( + subprocess.check_output( + ["git", "rev-parse", "--show-toplevel"], + text=True, + stderr=subprocess.DEVNULL, + ).strip() + ).resolve() + except (subprocess.CalledProcessError, FileNotFoundError): + pass + # Fallback: script location + if project_root is None: + script_project = Path(__file__).resolve().parents[2] + if (script_project / ".git").exists() or (script_project / ".zed-hours").exists(): + project_root = script_project + # Final fallback: cwd + if project_root is None: + project_root = Path.cwd().resolve() + + config = load_config(project_root) + tracker_loop(project_root, config) + + +if __name__ == "__main__": + main()