Files
timemaster/timetrack.py
T
sysops 1fedd683e0 Initial commit – TimeMaster Zeiterfassung & HR-Tool
Stand: agent-06 (Audit-Log), agent-05 (Krankmeldung), agent-07 Phase 1 (Personalnummer),
Busylight-Pull-Integration, TOTP/2FA, Abwesenheiten, Zeiterfassung, Kiosk-Grundgerüst.
Migrations 0001–0023 deployed auf 192.168.1.137 + .164.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-23 20:03:27 +02:00

318 lines
9.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()