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