488 lines
14 KiB
Python
Executable File
488 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Time tracker for Waybar.
|
|
|
|
Usage:
|
|
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
|
|
time_track.py current -- show the active session, if any
|
|
time_track.py stop -- stop the active session
|
|
time_track.py sync -- upload finished sessions to a server
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
import uuid
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from urllib.error import HTTPError, URLError
|
|
from urllib.parse import urlsplit
|
|
from urllib.request import Request, urlopen
|
|
|
|
DATA_DIR = Path(
|
|
os.environ.get("TIME_TRACK_DIR", Path.home() / ".local" / "share" / "time_track")
|
|
)
|
|
DATA_FILE = DATA_DIR / "data.json"
|
|
DEFAULT_TASK = ""
|
|
SYNC_TIMEOUT_SECONDS = 15
|
|
|
|
|
|
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:
|
|
data = json.load(f)
|
|
migrated = False
|
|
entry_ids = set()
|
|
for entry in data.get("entries", []):
|
|
try:
|
|
entry_id = str(uuid.UUID(entry.get("id", "")))
|
|
except (AttributeError, TypeError, ValueError):
|
|
entry_id = ""
|
|
if not entry_id or entry_id in entry_ids:
|
|
entry_id = str(uuid.uuid4())
|
|
entry["id"] = entry_id
|
|
migrated = True
|
|
entry_ids.add(entry_id)
|
|
if migrated:
|
|
save_data(data)
|
|
return data
|
|
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 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()
|
|
|
|
if data["active"]:
|
|
start = parse_iso(data["current"]["start"])
|
|
end = datetime.now()
|
|
duration = int((end - start).total_seconds())
|
|
entry = {
|
|
"id": str(uuid.uuid4()),
|
|
"task": data["current"].get("task", ""),
|
|
"start": data["current"]["start"],
|
|
"end": end.isoformat(timespec="seconds"),
|
|
"duration": duration,
|
|
}
|
|
data["entries"].append(entry)
|
|
data["active"] = False
|
|
data["current"] = None
|
|
save_data(data)
|
|
label = entry["task"] or "(no task)"
|
|
print(
|
|
f"Stopped: {label} @ {format_time(end)} "
|
|
f"({format_duration(duration)})",
|
|
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():
|
|
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)
|
|
task = data["current"].get("task", "") or "(no task)"
|
|
waybar = {
|
|
"text": f"⏱ {elapsed_text}",
|
|
"tooltip": (
|
|
f"Task: {task}\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:
|
|
label = last_entry.get("task", "") or "(no task)"
|
|
tooltip = (
|
|
f"Last: {label} "
|
|
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 sync():
|
|
"""Upload all finished entries using environment-only configuration."""
|
|
server_url = os.environ.get("TIME_TRACK_SERVER_URL", "").rstrip("/")
|
|
token = os.environ.get("TIME_TRACK_SERVER_TOKEN", "")
|
|
device_id = os.environ.get("TIME_TRACK_DEVICE_ID", "")
|
|
missing = [
|
|
name
|
|
for name, value in (
|
|
("TIME_TRACK_SERVER_URL", server_url),
|
|
("TIME_TRACK_SERVER_TOKEN", token),
|
|
("TIME_TRACK_DEVICE_ID", device_id),
|
|
)
|
|
if not value
|
|
]
|
|
if missing:
|
|
print(
|
|
"Sync is not configured. Set " + ", ".join(missing) + ".",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
parsed_url = urlsplit(server_url)
|
|
if parsed_url.scheme not in {"http", "https"} or not parsed_url.netloc:
|
|
print(
|
|
"Sync is not configured: TIME_TRACK_SERVER_URL must be an absolute HTTP(S) URL.",
|
|
file=sys.stderr,
|
|
)
|
|
return 1
|
|
|
|
data = load_data()
|
|
entries = [
|
|
{
|
|
"id": entry["id"],
|
|
"task": entry.get("task", ""),
|
|
"start": entry["start"],
|
|
"end": entry["end"],
|
|
"duration": entry["duration"],
|
|
}
|
|
for entry in data["entries"]
|
|
]
|
|
payload = json.dumps({"device_id": device_id, "entries": entries}).encode("utf-8")
|
|
request = Request(
|
|
f"{server_url}/api/v1/sync/time-track",
|
|
data=payload,
|
|
headers={
|
|
"Authorization": f"Bearer {token}",
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
},
|
|
method="POST",
|
|
)
|
|
|
|
try:
|
|
with urlopen(request, timeout=SYNC_TIMEOUT_SECONDS) as response:
|
|
status = response.status
|
|
response.read()
|
|
except HTTPError as error:
|
|
print(f"Sync failed: server returned HTTP {error.code}.", file=sys.stderr)
|
|
return 1
|
|
except URLError as error:
|
|
reason = error.reason
|
|
print(f"Sync failed: could not reach server ({reason}).", file=sys.stderr)
|
|
return 1
|
|
except (OSError, ValueError) as error:
|
|
print(f"Sync failed: request error ({error}).", file=sys.stderr)
|
|
return 1
|
|
|
|
print(f"Synced {len(entries)} finished entr{'y' if len(entries) == 1 else 'ies'} (HTTP {status}).")
|
|
return 0
|
|
|
|
|
|
def current():
|
|
data = load_data()
|
|
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"Task: {task}\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} {'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"{label: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_task: 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)
|
|
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)} | {label} | "
|
|
f"{format_duration(duration)} |"
|
|
)
|
|
|
|
total = sum(by_task.values())
|
|
|
|
print(f"# Timesheet: {period} ({start_date} → {end_date})")
|
|
print()
|
|
print("| Date | Start | End | Task | Duration |")
|
|
print("|------|-------|-----|------|----------|")
|
|
for row in rows:
|
|
print(row)
|
|
print()
|
|
print("## Summary by task")
|
|
print()
|
|
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")
|
|
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(
|
|
"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")
|
|
subparsers.add_parser("stop", help="stop the active session")
|
|
subparsers.add_parser("sync", help="upload finished sessions to the configured server")
|
|
|
|
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.task)
|
|
elif args.command == "status":
|
|
status()
|
|
elif args.command == "current":
|
|
current()
|
|
elif args.command == "stop":
|
|
stop()
|
|
elif args.command == "sync":
|
|
return sync()
|
|
elif args.command == "log":
|
|
log_cmd(args.count)
|
|
elif args.command == "timesheet":
|
|
timesheet(args.period)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main() or 0)
|