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:
2026-09-02 13:26:45 +02:00
parent baa1b30b4d
commit 2b33045e3f
2 changed files with 161 additions and 39 deletions
+136 -35
View File
@@ -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":