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>
This commit is contained in:
sysops
2026-05-23 20:03:27 +02:00
commit 1fedd683e0
178 changed files with 29896 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
#!/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()