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:
@@ -0,0 +1,176 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
dev_session_doc.py – Dokumentiert die zuletzt abgeschlossene Timetrack-Session in DEVLOG.md.
|
||||
Wird automatisch vom Claude Code Stop-Hook aufgerufen.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from datetime import datetime, timezone
|
||||
|
||||
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
DATA_FILE = os.path.expanduser("~/.claude/timetrack.json")
|
||||
DEVLOG = os.path.join(BASE_DIR, "DEVLOG.md")
|
||||
BACKEND_DIR = os.path.join(BASE_DIR, "backend")
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def parse_iso(s: str) -> datetime:
|
||||
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 local_display(dt: datetime) -> str:
|
||||
return dt.astimezone().strftime("%H:%M")
|
||||
|
||||
|
||||
def local_date(dt: datetime) -> str:
|
||||
return dt.astimezone().strftime("%Y-%m-%d")
|
||||
|
||||
|
||||
def run(cmd: list[str]) -> str:
|
||||
"""Run a command and return stdout; return '' on any error."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
# ── Core logic ────────────────────────────────────────────────────────────────
|
||||
|
||||
def get_last_completed_session(data: dict) -> dict | None:
|
||||
"""Return the most recently *completed* session (end != None)."""
|
||||
completed = [s for s in data.get("sessions", []) if s.get("end") is not None]
|
||||
if not completed:
|
||||
return None
|
||||
return max(completed, key=lambda s: s["end"])
|
||||
|
||||
|
||||
def get_commits(start_iso: str, end_iso: str) -> list[str]:
|
||||
"""Return list of 'HASH message' strings for commits within the session window."""
|
||||
start_dt = parse_iso(start_iso)
|
||||
end_dt = parse_iso(end_iso)
|
||||
|
||||
# git log uses local times via --after/--before; pass UTC explicitly as ISO
|
||||
after = start_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
before = end_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
raw = run([
|
||||
"git", "-C", BACKEND_DIR,
|
||||
"log", "--oneline",
|
||||
f"--after={after}",
|
||||
f"--before={before}",
|
||||
])
|
||||
if not raw:
|
||||
return []
|
||||
return [line for line in raw.splitlines() if line.strip()]
|
||||
|
||||
|
||||
def get_diff_stat() -> list[str]:
|
||||
"""Return lines from git diff --stat HEAD~1 HEAD."""
|
||||
raw = run([
|
||||
"git", "-C", BACKEND_DIR,
|
||||
"diff", "--stat", "HEAD~1", "HEAD",
|
||||
])
|
||||
if not raw:
|
||||
return []
|
||||
lines = raw.splitlines()
|
||||
# Drop the summary line (last line with "N files changed …")
|
||||
return [l for l in lines if l.strip() and " | " in l]
|
||||
|
||||
|
||||
def build_entry(session: dict) -> str:
|
||||
start_dt = parse_iso(session["start"])
|
||||
end_dt = parse_iso(session["end"])
|
||||
elapsed = (end_dt - start_dt).total_seconds()
|
||||
duration = format_duration(elapsed)
|
||||
|
||||
date_str = local_date(start_dt)
|
||||
start_str = local_display(start_dt)
|
||||
end_str = local_display(end_dt)
|
||||
desc = session.get("description") or "Claude Code Session"
|
||||
|
||||
commits = get_commits(session["start"], session["end"])
|
||||
diff_stat = get_diff_stat()
|
||||
|
||||
lines: list[str] = []
|
||||
lines.append(f"## {date_str} {start_str} – {end_str} ({duration})")
|
||||
lines.append(f"**Beschreibung:** {desc}")
|
||||
lines.append("")
|
||||
|
||||
# Commits section
|
||||
lines.append("### Commits")
|
||||
if commits:
|
||||
for c in commits:
|
||||
lines.append(f"- {c}")
|
||||
else:
|
||||
lines.append("Keine Commits in dieser Session.")
|
||||
lines.append("")
|
||||
|
||||
# Changed files section
|
||||
lines.append("### Geänderte Dateien")
|
||||
if diff_stat:
|
||||
for l in diff_stat:
|
||||
# Normalize: " path/file | 5 ++-" → "- path/file (+5/-2)"
|
||||
# Keep it simple and just relay the stat line
|
||||
lines.append(f"- {l.strip()}")
|
||||
else:
|
||||
lines.append("Keine Änderungen ermittelbar.")
|
||||
lines.append("")
|
||||
lines.append("---")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def append_to_devlog(entry: str) -> None:
|
||||
# Ensure file exists with a header
|
||||
if not os.path.exists(DEVLOG):
|
||||
with open(DEVLOG, "w", encoding="utf-8") as f:
|
||||
f.write("# TimeMaster – Dev Log\n\n")
|
||||
|
||||
with open(DEVLOG, "a", encoding="utf-8") as f:
|
||||
f.write(entry)
|
||||
|
||||
|
||||
# ── Entry point ───────────────────────────────────────────────────────────────
|
||||
|
||||
def main() -> None:
|
||||
if not os.path.exists(DATA_FILE):
|
||||
return # Nothing to document
|
||||
|
||||
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
session = get_last_completed_session(data)
|
||||
if not session:
|
||||
return
|
||||
|
||||
entry = build_entry(session)
|
||||
append_to_devlog(entry)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
# Never crash Claude Code
|
||||
pass
|
||||
Reference in New Issue
Block a user