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:
+317
@@ -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()
|
||||
Reference in New Issue
Block a user