#!/usr/bin/env python3 """TimeMaster – Development Session Tracker""" import json import sys import os import argparse from datetime import datetime, timezone, timedelta DATA_FILE = os.path.expanduser("~/.claude/timetrack.json") # ANSI color codes RESET = "\033[0m" BOLD = "\033[1m" DIM = "\033[2m" RED = "\033[91m" GREEN = "\033[92m" YELLOW = "\033[93m" BLUE = "\033[94m" MAGENTA = "\033[95m" CYAN = "\033[96m" WHITE = "\033[97m" def colorize(text: str, *codes: str) -> str: return "".join(codes) + text + RESET def load_data() -> dict: if not os.path.exists(DATA_FILE): return {"sessions": []} with open(DATA_FILE, "r", encoding="utf-8") as f: return json.load(f) def save_data(data: dict) -> None: with open(DATA_FILE, "w", encoding="utf-8") as f: json.dump(data, f, indent=2, ensure_ascii=False) def now_iso() -> str: return datetime.now(timezone.utc).isoformat() def parse_iso(s: str) -> datetime: # Python 3.10 fromisoformat handles Z, but 3.8/3.9 don't – be safe if s.endswith("Z"): s = s[:-1] + "+00:00" return datetime.fromisoformat(s) def format_duration(seconds: float) -> str: seconds = int(seconds) h = seconds // 3600 m = (seconds % 3600) // 60 if h > 0: return f"{h}h {m:02d}m" return f"{m}m" def get_running_session(data: dict) -> dict | None: for s in data["sessions"]: if s["end"] is None: return s return None def local_date(dt: datetime) -> str: """Return YYYY-MM-DD in local time.""" return dt.astimezone().strftime("%Y-%m-%d") def local_display(dt: datetime) -> str: return dt.astimezone().strftime("%H:%M") # ── Commands ────────────────────────────────────────────────────────────────── def cmd_start(args) -> None: description = args.description or "" data = load_data() running = get_running_session(data) if running: started_at = parse_iso(running["start"]) elapsed = (datetime.now(timezone.utc) - started_at).total_seconds() print( colorize("Fehler: ", RED, BOLD) + "Es läuft bereits eine Session.\n" + f" Gestartet: {colorize(local_display(started_at), CYAN)} " + f"Dauer: {colorize(format_duration(elapsed), YELLOW)}" + (f"\n Beschreibung: {running['description']}" if running["description"] else "") ) print(colorize("\nTipp: ", DIM) + colorize("python timetrack.py stop", WHITE) + " um sie zu beenden.") sys.exit(1) session = {"start": now_iso(), "end": None, "description": description} data["sessions"].append(session) save_data(data) started_dt = parse_iso(session["start"]) print( colorize("Session gestartet", GREEN, BOLD) + f" {colorize(local_display(started_dt), CYAN)}" + (f"\n {colorize(description, WHITE)}" if description else "") ) def cmd_stop(args) -> None: data = load_data() running = get_running_session(data) if not running: print( colorize("Fehler: ", RED, BOLD) + "Keine laufende Session gefunden." ) print(colorize("\nTipp: ", DIM) + colorize("python timetrack.py start", WHITE) + " um eine zu starten.") sys.exit(1) end_iso = now_iso() running["end"] = end_iso save_data(data) started_dt = parse_iso(running["start"]) ended_dt = parse_iso(end_iso) elapsed = (ended_dt - started_dt).total_seconds() print( colorize("Session beendet", MAGENTA, BOLD) + f" {colorize(local_display(started_dt), CYAN)} → {colorize(local_display(ended_dt), CYAN)}" + f" Dauer: {colorize(format_duration(elapsed), YELLOW, BOLD)}" + (f"\n {colorize(running['description'], WHITE)}" if running["description"] else "") ) def cmd_status(args) -> None: data = load_data() running = get_running_session(data) if not running: print(colorize("Keine laufende Session.", DIM)) return started_dt = parse_iso(running["start"]) elapsed = (datetime.now(timezone.utc) - started_dt).total_seconds() print(colorize("Laufende Session", GREEN, BOLD)) print(f" Gestartet : {colorize(started_dt.astimezone().strftime('%Y-%m-%d %H:%M'), CYAN)}") print(f" Dauer : {colorize(format_duration(elapsed), YELLOW, BOLD)}") if running["description"]: print(f" Beschr. : {colorize(running['description'], WHITE)}") def cmd_report(args) -> None: data = load_data() sessions = data["sessions"] # Determine filter now_local = datetime.now(timezone.utc).astimezone() filter_label = "" if args.week: # Monday of current week week_start = (now_local - timedelta(days=now_local.weekday())).replace( hour=0, minute=0, second=0, microsecond=0 ) week_end = week_start + timedelta(days=7) def in_range(start_dt): loc = start_dt.astimezone() return week_start <= loc < week_end filter_label = f"KW {now_local.isocalendar()[1]} ({week_start.strftime('%d.%m.')} – {(week_end - timedelta(days=1)).strftime('%d.%m.%Y')})" elif args.month: def in_range(start_dt): loc = start_dt.astimezone() return loc.year == now_local.year and loc.month == now_local.month filter_label = now_local.strftime("%B %Y") else: def in_range(_): return True filter_label = "Gesamt" # Build filtered & sorted list filtered = [] for s in sessions: start_dt = parse_iso(s["start"]) if not in_range(start_dt): continue if s["end"] is not None: end_dt = parse_iso(s["end"]) elapsed = (end_dt - start_dt).total_seconds() ongoing = False else: end_dt = datetime.now(timezone.utc) elapsed = (end_dt - start_dt).total_seconds() ongoing = True filtered.append({ "date": local_date(start_dt), "start_dt": start_dt, "end_dt": end_dt, "elapsed": elapsed, "description": s["description"], "ongoing": ongoing, }) filtered.sort(key=lambda x: x["start_dt"]) if not filtered: print(colorize(f"Keine Sessions für: {filter_label}", DIM)) return # Column widths COL_DATE = 12 COL_TIME = 13 # HH:MM – HH:MM COL_DUR = 9 COL_DESC = 40 sep_char = "─" total_width = COL_DATE + COL_TIME + COL_DUR + COL_DESC + 3 # 3 separators def hr(char=sep_char): return colorize(char * total_width, DIM) def row(date, time_range, duration, description, color=WHITE): d = date.ljust(COL_DATE) t = time_range.ljust(COL_TIME) dur = duration.ljust(COL_DUR) desc = description[:COL_DESC].ljust(COL_DESC) return colorize(f"{d} {t} {dur} {desc}", color) # Header print() print(colorize(f" TimeMaster – Report: {filter_label}", BOLD, BLUE)) print(hr()) print(row( colorize("Datum", BOLD, WHITE), colorize("Zeit", BOLD, WHITE), colorize("Dauer", BOLD, WHITE), colorize("Beschreibung",BOLD, WHITE), )) print(hr()) # Group by date day_groups: dict[str, list] = {} for entry in filtered: day_groups.setdefault(entry["date"], []).append(entry) grand_total = 0.0 for date_str, entries in day_groups.items(): day_total = 0.0 for e in entries: time_range = f"{local_display(e['start_dt'])}–{local_display(e['end_dt'])}" dur_str = format_duration(e["elapsed"]) if e["ongoing"]: dur_str += colorize(" ●", GREEN) time_range += colorize(" ●", GREEN) print(row( date_str, time_range, dur_str, e["description"] or colorize("–", DIM), )) day_total += e["elapsed"] grand_total += e["elapsed"] date_str = "" # only show date on first row per day # Day subtotal day_label = colorize(f" Tagessumme", DIM) day_dur = colorize(format_duration(day_total), YELLOW, BOLD) print(f"{' ' * COL_DATE} {'':>{COL_TIME}} {day_dur}") print(hr()) # Grand total print( f"{'Gesamt'.ljust(COL_DATE + COL_TIME + 2)}" + colorize(format_duration(grand_total), CYAN, BOLD) ) print() # ── Entry point ─────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser( prog="timetrack", description="TimeMaster – Development Session Tracker", ) sub = parser.add_subparsers(dest="command", metavar="command") p_start = sub.add_parser("start", help="Session starten") p_start.add_argument("description", nargs="?", default="", help="Optionale Beschreibung") sub.add_parser("stop", help="Laufende Session beenden") sub.add_parser("status", help="Status der laufenden Session") p_report = sub.add_parser("report", help="Sessionen als Tabelle anzeigen") grp = p_report.add_mutually_exclusive_group() grp.add_argument("--week", action="store_true", help="Nur aktuelle Woche") grp.add_argument("--month", action="store_true", help="Nur aktueller Monat") args = parser.parse_args() dispatch = { "start": cmd_start, "stop": cmd_stop, "status": cmd_status, "report": cmd_report, } if args.command not in dispatch: parser.print_help() sys.exit(1) dispatch[args.command](args) if __name__ == "__main__": main()