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:
Executable
+290
@@ -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()
|
||||
Reference in New Issue
Block a user