1fedd683e0
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>
318 lines
9.9 KiB
Python
318 lines
9.9 KiB
Python
#!/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()
|