commit 94566aa82078d6ed6a5ceea3e0fc21a7b7103d17 Author: Florian Egger Date: Wed Sep 2 12:29:01 2026 +0200 Add Waybar time tracker with README, styles, and config Includes a dependency-free Python CLI, Waybar JSON output, persistent JSON storage, and Markdown timesheet generation grouped by project and day. Also adds example Waybar configuration and CSS. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3468ccf --- /dev/null +++ b/README.md @@ -0,0 +1,63 @@ +# Waybar Time Tracker + +A small, dependency-free Python time tracker for Waybar with a start/stop button and timesheet generation. + +## Features + +- **Start / stop button** in Waybar: click the module to toggle tracking. +- **Live elapsed time** shown in the bar while running. +- **Persistent JSON storage** in `~/.local/share/time_track/data.json` (override with `TIME_TRACK_DIR`). +- **Timesheet generation** in Markdown, grouped by project and by day. + +## Files + +| File | Purpose | +|------|---------| +| `time_track.py` | Main CLI and Waybar interface | +| `waybar-config.json` | Example Waybar custom module configuration | +| `style.css` | Example CSS for the running/idle states | + +## Installation + +1. Make sure `time_track.py` is executable: + + ```sh + chmod +x /path/to/time_track/time_track.py + ``` + +2. Add the custom module from `waybar-config.json` to your Waybar config (`~/.config/waybar/config`). +3. Add the CSS from `style.css` to your Waybar stylesheet (`~/.config/waybar/style.css`). +4. Update the paths in `waybar-config.json` to point to wherever you place `time_track.py`. + +## CLI usage + +```sh +# Toggle tracking for the default project +./time_track.py toggle + +# Toggle tracking for a specific project +./time_track.py toggle "client-a" + +# Emit Waybar JSON (used by the module) +./time_track.py status + +# Show the active session +./time_track.py current + +# Stop the active session +./time_track.py stop + +# Show recent entries +./time_track.py log +./time_track.py log 25 + +# Generate a Markdown timesheet +./time_track.py timesheet week +./time_track.py timesheet month +``` + +## Waybar behavior + +- **Left click**: start or stop the current session. +- **Right click**: print the current week's Markdown timesheet to stdout (useful bound to a notification or terminal). +- The module updates every 5 seconds while running. diff --git a/__pycache__/time_track.cpython-314.pyc b/__pycache__/time_track.cpython-314.pyc new file mode 100644 index 0000000..08b73b9 Binary files /dev/null and b/__pycache__/time_track.cpython-314.pyc differ diff --git a/style.css b/style.css new file mode 100644 index 0000000..1a7defb --- /dev/null +++ b/style.css @@ -0,0 +1,17 @@ +/* Waybar CSS for the time tracker module */ +#custom-timetrack { + padding: 0 12px; + border-radius: 4px; + margin: 4px 2px; + font-weight: bold; +} + +#custom-timetrack.idle { + background-color: #3b3b3b; + color: #a0a0a0; +} + +#custom-timetrack.running { + background-color: #2e7d32; + color: #ffffff; +} diff --git a/time_track.py b/time_track.py new file mode 100755 index 0000000..976bb17 --- /dev/null +++ b/time_track.py @@ -0,0 +1,290 @@ +#!/usr/bin/env python3 +"""Time tracker for Waybar. + +Usage: + time_track.py toggle [project_name] -- start or stop a session + time_track.py status -- emit Waybar JSON + time_track.py log [n] -- show last n entries (default 10) + time_track.py timesheet [week|month] -- generate a Markdown timesheet + time_track.py current -- show the active session, if any + time_track.py stop -- stop the active session +""" + +import argparse +import json +import os +import sys +from datetime import datetime, timedelta +from pathlib import Path + +DATA_DIR = Path( + os.environ.get("TIME_TRACK_DIR", Path.home() / ".local" / "share" / "time_track") +) +DATA_FILE = DATA_DIR / "data.json" +DEFAULT_PROJECT = "default" + + +def load_data(): + DATA_DIR.mkdir(parents=True, exist_ok=True) + if DATA_FILE.exists(): + try: + with DATA_FILE.open("r", encoding="utf-8") as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + pass + return {"active": False, "current": None, "entries": []} + + +def save_data(data): + DATA_DIR.mkdir(parents=True, exist_ok=True) + with DATA_FILE.open("w", encoding="utf-8") as f: + json.dump(data, f, indent=2) + + +def now_iso(): + return datetime.now().isoformat(timespec="seconds") + + +def parse_iso(value): + return datetime.fromisoformat(value) + + +def format_duration(seconds): + sign = "-" if seconds < 0 else "" + seconds = abs(int(seconds)) + hours, remainder = divmod(seconds, 3600) + minutes, secs = divmod(remainder, 60) + parts = [] + if hours: + parts.append(f"{hours}h") + if minutes: + parts.append(f"{minutes}m") + if not hours and not minutes: + parts.append(f"{secs}s") + return sign + " ".join(parts) + + +def format_time(dt): + return dt.strftime("%H:%M") + + +def format_date(dt): + return dt.strftime("%Y-%m-%d") + + +def toggle(project=None): + data = load_data() + project = project or DEFAULT_PROJECT + + if data["active"]: + start = parse_iso(data["current"]["start"]) + end = datetime.now() + duration = int((end - start).total_seconds()) + entry = { + "project": data["current"]["project"], + "start": data["current"]["start"], + "end": end.isoformat(timespec="seconds"), + "duration": duration, + } + data["entries"].append(entry) + data["active"] = False + data["current"] = None + save_data(data) + print( + f"Stopped: {entry['project']} @ {format_time(end)} " + f"({format_duration(duration)})", + file=sys.stderr, + ) + else: + data["active"] = True + data["current"] = { + "project": project, + "start": now_iso(), + } + save_data(data) + print( + f"Started: {project} @ {format_time(datetime.now())}", + file=sys.stderr, + ) + + +def stop(): + data = load_data() + if not data["active"]: + print("No active session.", file=sys.stderr) + return + toggle() + + +def status(): + data = load_data() + if data["active"]: + start = parse_iso(data["current"]["start"]) + elapsed = int((datetime.now() - start).total_seconds()) + elapsed_text = format_duration(elapsed) + waybar = { + "text": f"⏱ {elapsed_text}", + "tooltip": ( + f"Project: {data['current']['project']}\n" + f"Started: {format_time(start)} ({format_date(start)})\n" + f"Elapsed: {elapsed_text}" + ), + "class": "running", + } + else: + last_entry = data["entries"][-1] if data["entries"] else None + if last_entry: + tooltip = ( + f"Last: {last_entry['project']} " + f"@ {format_time(parse_iso(last_entry['end']))} " + f"({format_duration(last_entry['duration'])}). Click to start." + ) + else: + tooltip = "Click to start tracking." + waybar = { + "text": "⏱ --:--", + "tooltip": tooltip, + "class": "idle", + } + print(json.dumps(waybar)) + + +def current(): + data = load_data() + if data["active"]: + start = parse_iso(data["current"]["start"]) + elapsed = int((datetime.now() - start).total_seconds()) + print( + f"Project: {data['current']['project']}\n" + f"Started: {start.isoformat(' ', timespec='seconds')}\n" + f"Elapsed: {format_duration(elapsed)}" + ) + else: + print("No active session.") + + +def log_cmd(count=10): + data = load_data() + entries = data["entries"][-count:] + if not entries: + print("No logged entries yet.") + return + print(f"{'Start':20} {'End':20} {'Project':12} {'Duration':10}") + print("-" * 65) + for e in entries: + start = parse_iso(e["start"]) + end = parse_iso(e["end"]) + print( + f"{start.isoformat(' ', timespec='seconds'):20} " + f"{end.isoformat(' ', timespec='seconds'):20} " + f"{e['project']:12} {format_duration(e['duration']):>10}" + ) + + +def timesheet(period="week"): + data = load_data() + today = datetime.now().date() + + if period == "week": + start_date = today - timedelta(days=today.weekday()) + end_date = start_date + timedelta(days=6) + elif period == "month": + start_date = today.replace(day=1) + next_month = (today.replace(day=28) + timedelta(days=4)).replace(day=1) + end_date = (next_month - timedelta(days=1)) + else: + start_date = today - timedelta(days=7) + end_date = today + + entries = [ + e for e in data["entries"] + if start_date <= parse_iso(e["start"]).date() <= end_date + ] + + if not entries: + print(f"No entries for {period} ({start_date} → {end_date}).") + return + + by_project: dict[str, int] = {} + by_date: dict[str, int] = {} + rows = [] + + for e in entries: + start = parse_iso(e["start"]) + end = parse_iso(e["end"]) + duration = e["duration"] + date_key = format_date(start) + by_project[e["project"]] = by_project.get(e["project"], 0) + duration + by_date[date_key] = by_date.get(date_key, 0) + duration + rows.append( + f"| {format_date(start)} | {format_time(start)} | " + f"{format_time(end)} | {e['project']} | " + f"{format_duration(duration)} |" + ) + + total = sum(by_project.values()) + + print(f"# Timesheet: {period} ({start_date} → {end_date})") + print() + print("| Date | Start | End | Project | Duration |") + print("|------|-------|-----|---------|----------|") + for row in rows: + print(row) + print() + print("## Summary by project") + print() + print("| Project | Total |") + print("|---------|-------|") + for project, seconds in sorted(by_project.items()): + print(f"| {project} | {format_duration(seconds)} |") + print(f"| **Total** | **{format_duration(total)}** |") + print() + print("## Summary by day") + print() + print("| Day | Total |") + print("|-----|-------|") + for date_key in sorted(by_date): + print(f"| {date_key} | {format_duration(by_date[date_key])} |") + + +def main(): + parser = argparse.ArgumentParser(description="Waybar time tracker") + subparsers = parser.add_subparsers(dest="command", required=True) + + p_toggle = subparsers.add_parser("toggle", help="start or stop a session") + p_toggle.add_argument("project", nargs="?", default=DEFAULT_PROJECT) + + subparsers.add_parser("status", help="emit Waybar JSON") + subparsers.add_parser("current", help="show the active session") + subparsers.add_parser("stop", help="stop the active session") + + p_log = subparsers.add_parser("log", help="show recent entries") + p_log.add_argument("count", nargs="?", type=int, default=10) + + p_sheet = subparsers.add_parser("timesheet", help="generate a Markdown timesheet") + p_sheet.add_argument( + "period", + nargs="?", + choices=["week", "month"], + default="week", + help="time period to summarize", + ) + + args = parser.parse_args() + + if args.command == "toggle": + toggle(args.project) + elif args.command == "status": + status() + elif args.command == "current": + current() + elif args.command == "stop": + stop() + elif args.command == "log": + log_cmd(args.count) + elif args.command == "timesheet": + timesheet(args.period) + + +if __name__ == "__main__": + main() diff --git a/waybar-config.json b/waybar-config.json new file mode 100644 index 0000000..c9ee5cb --- /dev/null +++ b/waybar-config.json @@ -0,0 +1,12 @@ +{ + "modules-center": ["custom/timetrack"], + "custom/timetrack": { + "exec": "/home/fegger/Code/projects/time_track/time_track.py status", + "interval": 5, + "format": "{}", + "return-type": "json", + "on-click": "/home/fegger/Code/projects/time_track/time_track.py toggle", + "on-click-right": "/home/fegger/Code/projects/time_track/time_track.py timesheet week", + "tooltip": true + } +}