94566aa820
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.
291 lines
8.4 KiB
Python
Executable File
291 lines
8.4 KiB
Python
Executable File
#!/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()
|