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:
@@ -0,0 +1,63 @@
|
|||||||
|
# Waybar Time Tracker
|
||||||
|
|
||||||
|
A small, dependency-free Python time tracker for Waybar with a start/stop button and timesheet generation.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **Start / stop button** in Waybar: click the module to toggle tracking.
|
||||||
|
- **Live elapsed time** shown in the bar while running.
|
||||||
|
- **Persistent JSON storage** in `~/.local/share/time_track/data.json` (override with `TIME_TRACK_DIR`).
|
||||||
|
- **Timesheet generation** in Markdown, grouped by project and by day.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
| File | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| `time_track.py` | Main CLI and Waybar interface |
|
||||||
|
| `waybar-config.json` | Example Waybar custom module configuration |
|
||||||
|
| `style.css` | Example CSS for the running/idle states |
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
1. Make sure `time_track.py` is executable:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
chmod +x /path/to/time_track/time_track.py
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Add the custom module from `waybar-config.json` to your Waybar config (`~/.config/waybar/config`).
|
||||||
|
3. Add the CSS from `style.css` to your Waybar stylesheet (`~/.config/waybar/style.css`).
|
||||||
|
4. Update the paths in `waybar-config.json` to point to wherever you place `time_track.py`.
|
||||||
|
|
||||||
|
## CLI usage
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Toggle tracking for the default project
|
||||||
|
./time_track.py toggle
|
||||||
|
|
||||||
|
# Toggle tracking for a specific project
|
||||||
|
./time_track.py toggle "client-a"
|
||||||
|
|
||||||
|
# Emit Waybar JSON (used by the module)
|
||||||
|
./time_track.py status
|
||||||
|
|
||||||
|
# Show the active session
|
||||||
|
./time_track.py current
|
||||||
|
|
||||||
|
# Stop the active session
|
||||||
|
./time_track.py stop
|
||||||
|
|
||||||
|
# Show recent entries
|
||||||
|
./time_track.py log
|
||||||
|
./time_track.py log 25
|
||||||
|
|
||||||
|
# Generate a Markdown timesheet
|
||||||
|
./time_track.py timesheet week
|
||||||
|
./time_track.py timesheet month
|
||||||
|
```
|
||||||
|
|
||||||
|
## Waybar behavior
|
||||||
|
|
||||||
|
- **Left click**: start or stop the current session.
|
||||||
|
- **Right click**: print the current week's Markdown timesheet to stdout (useful bound to a notification or terminal).
|
||||||
|
- The module updates every 5 seconds while running.
|
||||||
Binary file not shown.
@@ -0,0 +1,17 @@
|
|||||||
|
/* Waybar CSS for the time tracker module */
|
||||||
|
#custom-timetrack {
|
||||||
|
padding: 0 12px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 4px 2px;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
#custom-timetrack.idle {
|
||||||
|
background-color: #3b3b3b;
|
||||||
|
color: #a0a0a0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#custom-timetrack.running {
|
||||||
|
background-color: #2e7d32;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
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()
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"modules-center": ["custom/timetrack"],
|
||||||
|
"custom/timetrack": {
|
||||||
|
"exec": "/home/fegger/Code/projects/time_track/time_track.py status",
|
||||||
|
"interval": 5,
|
||||||
|
"format": "{}",
|
||||||
|
"return-type": "json",
|
||||||
|
"on-click": "/home/fegger/Code/projects/time_track/time_track.py toggle",
|
||||||
|
"on-click-right": "/home/fegger/Code/projects/time_track/time_track.py timesheet week",
|
||||||
|
"tooltip": true
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user