diff --git a/README.md b/README.md index fb51a8b..55a8633 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ A small, dependency-free Python time tracker for Waybar with a start/stop button - **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. +- **Timesheet generation** in Markdown, grouped by task and by day. ## Files @@ -37,12 +37,15 @@ If the Waybar module appears but has no text, check that `"format"` is **not emp ## CLI usage ```sh -# Toggle tracking for the default project +# Start a session (will prompt for a task; empty is allowed) ./time_track.py toggle -# Toggle tracking for a specific project +# Start a session without a prompt, using a specific task ./time_track.py toggle "client-a" +# Stop the active session (same as ./time_track.py stop) +./time_track.py toggle + # Emit Waybar JSON (used by the module) ./time_track.py status @@ -63,6 +66,24 @@ If the Waybar module appears but has no text, check that `"format"` is **not emp ## Waybar behavior -- **Left click**: start or stop the current session. +- **Left click**: start or stop a session. + - When starting, a task prompt appears. The launcher order is `wofi`, `rofi`, `bemenu`, `dmenu`, then `zenity`, then a terminal fallback. + - Existing tasks are shown as a dropdown/list. Type a new task or pick an existing one. + - Press Enter with no input to start with an empty task. + - Press Escape / Cancel to abort starting. - **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. + +## Task prompting requirements + +For the click-to-start prompt to work from Waybar you need one of these installed: + +| Launcher | Notes | +|----------|-------| +| `wofi` | Best for Wayland; shows previous tasks as a dropdown and allows typing new ones | +| `rofi` | Works on X11 and Wayland | +| `bemenu` | Wayland/X11 dmenu alternative | +| `dmenu` | Classic suckless dmenu | +| `zenity` | GTK entry dialog (no dropdown, but supports empty input) | + +If none are installed, the script falls back to a terminal prompt when run from a TTY. diff --git a/time_track.py b/time_track.py index 976bb17..d872419 100755 --- a/time_track.py +++ b/time_track.py @@ -2,7 +2,7 @@ """Time tracker for Waybar. Usage: - time_track.py toggle [project_name] -- start or stop a session + time_track.py toggle [task] -- 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 @@ -13,6 +13,8 @@ Usage: import argparse import json import os +import shutil +import subprocess import sys from datetime import datetime, timedelta from pathlib import Path @@ -21,7 +23,7 @@ DATA_DIR = Path( os.environ.get("TIME_TRACK_DIR", Path.home() / ".local" / "share" / "time_track") ) DATA_FILE = DATA_DIR / "data.json" -DEFAULT_PROJECT = "default" +DEFAULT_TASK = "" def load_data(): @@ -72,16 +74,95 @@ def format_date(dt): return dt.strftime("%Y-%m-%d") -def toggle(project=None): +def previous_tasks(data): + """Return a sorted list of unique task names from finished entries.""" + tasks = {e.get("task", "") for e in data["entries"]} + return sorted(t for t in tasks if t) + + +def prompt_for_task(data): + """Prompt the user for a task name. + + Returns the selected/typed task string. An empty string is allowed. + Returns None if the user cancelled the prompt. + """ + tasks = previous_tasks(data) + input_text = "\n".join(tasks) + + launchers = [ + ( + "wofi", + lambda: ["wofi", "--dmenu", "--prompt", "Enter task", "--insensitive"], + ), + ( + "rofi", + lambda: ["rofi", "-dmenu", "-p", "Enter task", "-i"], + ), + ( + "bemenu", + lambda: ["bemenu", "-p", "Enter task", "-i"], + ), + ( + "dmenu", + lambda: ["dmenu", "-p", "Enter task", "-i"], + ), + ] + + for binary, cmd in launchers: + if shutil.which(binary): + try: + result = subprocess.run( + cmd(), + input=input_text, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + except (OSError, subprocess.SubprocessError): + continue + + # Fallback to zenity (plain entry, no dropdown). + if shutil.which("zenity"): + try: + result = subprocess.run( + [ + "zenity", + "--entry", + "--title=Time Tracker", + "--text=Enter task (leave empty for none):", + ], + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + return None + return result.stdout.strip() + except (OSError, subprocess.SubprocessError): + pass + + # Terminal fallback. + print("Enter task (empty for none):", file=sys.stderr) + if tasks: + print("Previous tasks:", ", ".join(tasks), file=sys.stderr) + try: + return input("Task: ").strip() + except EOFError: + return None + + +def toggle(task=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"], + "task": data["current"].get("task", ""), "start": data["current"]["start"], "end": end.isoformat(timespec="seconds"), "duration": duration, @@ -90,22 +171,32 @@ def toggle(project=None): data["active"] = False data["current"] = None save_data(data) + label = entry["task"] or "(no task)" print( - f"Stopped: {entry['project']} @ {format_time(end)} " + f"Stopped: {label} @ {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, - ) + return + + # Starting a new session. + if task is None: + task = prompt_for_task(data) + if task is None: + print("Start cancelled.", file=sys.stderr) + return + + data["active"] = True + data["current"] = { + "task": task, + "start": now_iso(), + } + save_data(data) + label = task or "(no task)" + print( + f"Started: {label} @ {format_time(datetime.now())}", + file=sys.stderr, + ) def stop(): @@ -122,10 +213,11 @@ def status(): start = parse_iso(data["current"]["start"]) elapsed = int((datetime.now() - start).total_seconds()) elapsed_text = format_duration(elapsed) + task = data["current"].get("task", "") or "(no task)" waybar = { "text": f"⏱ {elapsed_text}", "tooltip": ( - f"Project: {data['current']['project']}\n" + f"Task: {task}\n" f"Started: {format_time(start)} ({format_date(start)})\n" f"Elapsed: {elapsed_text}" ), @@ -134,8 +226,9 @@ def status(): else: last_entry = data["entries"][-1] if data["entries"] else None if last_entry: + label = last_entry.get("task", "") or "(no task)" tooltip = ( - f"Last: {last_entry['project']} " + f"Last: {label} " f"@ {format_time(parse_iso(last_entry['end']))} " f"({format_duration(last_entry['duration'])}). Click to start." ) @@ -154,8 +247,9 @@ def current(): if data["active"]: start = parse_iso(data["current"]["start"]) elapsed = int((datetime.now() - start).total_seconds()) + task = data["current"].get("task", "") or "(no task)" print( - f"Project: {data['current']['project']}\n" + f"Task: {task}\n" f"Started: {start.isoformat(' ', timespec='seconds')}\n" f"Elapsed: {format_duration(elapsed)}" ) @@ -169,15 +263,16 @@ def log_cmd(count=10): if not entries: print("No logged entries yet.") return - print(f"{'Start':20} {'End':20} {'Project':12} {'Duration':10}") + print(f"{'Start':20} {'End':20} {'Task':12} {'Duration':10}") print("-" * 65) for e in entries: start = parse_iso(e["start"]) end = parse_iso(e["end"]) + label = e.get("task", "") or "(no task)" print( f"{start.isoformat(' ', timespec='seconds'):20} " f"{end.isoformat(' ', timespec='seconds'):20} " - f"{e['project']:12} {format_duration(e['duration']):>10}" + f"{label:12} {format_duration(e['duration']):>10}" ) @@ -205,7 +300,7 @@ def timesheet(period="week"): print(f"No entries for {period} ({start_date} → {end_date}).") return - by_project: dict[str, int] = {} + by_task: dict[str, int] = {} by_date: dict[str, int] = {} rows = [] @@ -214,29 +309,30 @@ def timesheet(period="week"): end = parse_iso(e["end"]) duration = e["duration"] date_key = format_date(start) - by_project[e["project"]] = by_project.get(e["project"], 0) + duration + label = e.get("task", "") or "(no task)" + by_task[label] = by_task.get(label, 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_time(end)} | {label} | " f"{format_duration(duration)} |" ) - total = sum(by_project.values()) + total = sum(by_task.values()) print(f"# Timesheet: {period} ({start_date} → {end_date})") print() - print("| Date | Start | End | Project | Duration |") - print("|------|-------|-----|---------|----------|") + print("| Date | Start | End | Task | Duration |") + print("|------|-------|-----|------|----------|") for row in rows: print(row) print() - print("## Summary by project") + print("## Summary by task") print() - print("| Project | Total |") - print("|---------|-------|") - for project, seconds in sorted(by_project.items()): - print(f"| {project} | {format_duration(seconds)} |") + print("| Task | Total |") + print("|------|-------|") + for task, seconds in sorted(by_task.items()): + print(f"| {task} | {format_duration(seconds)} |") print(f"| **Total** | **{format_duration(total)}** |") print() print("## Summary by day") @@ -252,7 +348,12 @@ def main(): 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) + p_toggle.add_argument( + "task", + nargs="?", + default=None, + help="task name (prompts if omitted and not stopping)", + ) subparsers.add_parser("status", help="emit Waybar JSON") subparsers.add_parser("current", help="show the active session") @@ -273,7 +374,7 @@ def main(): args = parser.parse_args() if args.command == "toggle": - toggle(args.project) + toggle(args.task) elif args.command == "status": status() elif args.command == "current":