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.
This commit is contained in:
2026-09-02 12:29:01 +02:00
commit 94566aa820
5 changed files with 382 additions and 0 deletions
Executable
+290
View File
@@ -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()