Replace project tracking with task-based tracking and interactive prompting
Change the time tracker concept from "project" to "task". When starting a session without an explicit task argument, the user is now prompted with their previous tasks via available launchers (wofi, rofi, bemenu, dmenu, zenity, or terminal fallback). Empty tasks are allowed. Update README, CLI output, Waybar tooltips, and timesheet generation accordingly.
This commit is contained in:
@@ -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.
|
||||
|
||||
+136
-35
@@ -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":
|
||||
|
||||
Reference in New Issue
Block a user