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
+185
View File
@@ -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.
+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()
+129
View File
@@ -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 (<project>/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()
+101
View File
@@ -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 (<project>/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()
+296
View File
@@ -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()