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>
195 lines
5.7 KiB
Python
195 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
dev_weekly.py – Wochenbericht: Gesamtstunden der aktuellen Woche + letzte DEVLOG-Einträge.
|
||
Aufruf: python3 dev_weekly.py
|
||
"""
|
||
|
||
import json
|
||
import os
|
||
import re
|
||
from datetime import datetime, timezone, timedelta
|
||
|
||
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")
|
||
|
||
# ANSI colors
|
||
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 c(*codes: str) -> str:
|
||
"""Return ANSI prefix string."""
|
||
return "".join(codes)
|
||
|
||
|
||
def colorize(text: str, *codes: str) -> str:
|
||
return "".join(codes) + text + RESET
|
||
|
||
|
||
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 current_week_range() -> tuple[datetime, datetime]:
|
||
"""Return (monday_00:00 local, sunday_23:59 local) for the current ISO week."""
|
||
now = datetime.now(timezone.utc).astimezone()
|
||
monday = (now - timedelta(days=now.weekday())).replace(
|
||
hour=0, minute=0, second=0, microsecond=0
|
||
)
|
||
sunday_end = monday + timedelta(days=7)
|
||
return monday, sunday_end
|
||
|
||
|
||
def weekly_stats(data: dict) -> tuple[float, int, dict[str, float]]:
|
||
"""Return (total_seconds, session_count, {date: seconds}) for this week."""
|
||
week_start, week_end = current_week_range()
|
||
total = 0.0
|
||
count = 0
|
||
per_day: dict[str, float] = {}
|
||
|
||
for s in data.get("sessions", []):
|
||
start_dt = parse_iso(s["start"])
|
||
loc = start_dt.astimezone()
|
||
if not (week_start <= loc < week_end):
|
||
continue
|
||
if s.get("end") is not None:
|
||
end_dt = parse_iso(s["end"])
|
||
elapsed = (end_dt - start_dt).total_seconds()
|
||
else:
|
||
elapsed = (datetime.now(timezone.utc) - start_dt).total_seconds()
|
||
day_key = loc.strftime("%Y-%m-%d")
|
||
per_day[day_key] = per_day.get(day_key, 0.0) + elapsed
|
||
total += elapsed
|
||
count += 1
|
||
|
||
return total, count, per_day
|
||
|
||
|
||
def get_last_devlog_entries(n: int = 5) -> list[str]:
|
||
"""
|
||
Parse DEVLOG.md and return the last n section blocks
|
||
(everything between ## headings).
|
||
"""
|
||
if not os.path.exists(DEVLOG):
|
||
return []
|
||
|
||
with open(DEVLOG, "r", encoding="utf-8") as f:
|
||
content = f.read()
|
||
|
||
# Split on ## headings (session entries)
|
||
parts = re.split(r"(?=^## )", content, flags=re.MULTILINE)
|
||
# Filter out non-entry parts (header, blanks)
|
||
entries = [p.strip() for p in parts if p.strip().startswith("## ")]
|
||
return entries[-n:]
|
||
|
||
|
||
def print_separator(char: str = "─", width: int = 60) -> None:
|
||
print(colorize(char * width, DIM))
|
||
|
||
|
||
def print_weekly_report() -> None:
|
||
if not os.path.exists(DATA_FILE):
|
||
print(colorize("Keine Timetrack-Daten gefunden.", DIM))
|
||
return
|
||
|
||
with open(DATA_FILE, "r", encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
|
||
total_sec, count, per_day = weekly_stats(data)
|
||
week_start, _ = current_week_range()
|
||
week_num = week_start.isocalendar()[1]
|
||
week_label = (
|
||
f"KW {week_num} "
|
||
f"({week_start.strftime('%d.%m.')} – "
|
||
f"{(week_start + timedelta(days=6)).strftime('%d.%m.%Y')})"
|
||
)
|
||
|
||
print()
|
||
print(colorize(f" TimeMaster – Wochenbericht {week_label}", BOLD, BLUE))
|
||
print_separator()
|
||
|
||
if not per_day:
|
||
print(colorize(" Keine Sessions diese Woche.", DIM))
|
||
else:
|
||
# Day-by-day breakdown
|
||
DAYS_DE = {
|
||
0: "Mo", 1: "Di", 2: "Mi", 3: "Do", 4: "Fr", 5: "Sa", 6: "So"
|
||
}
|
||
for day_str in sorted(per_day):
|
||
dt = datetime.fromisoformat(day_str)
|
||
day_name = DAYS_DE.get(dt.weekday(), "??")
|
||
dur = format_duration(per_day[day_str])
|
||
print(
|
||
f" {colorize(day_name, WHITE, BOLD)} "
|
||
f"{colorize(day_str, DIM)} "
|
||
f"{colorize(dur, YELLOW)}"
|
||
)
|
||
|
||
print_separator()
|
||
print(
|
||
f" {'Gesamt':.<20} "
|
||
+ colorize(format_duration(total_sec), CYAN, BOLD)
|
||
+ colorize(f" ({count} Sessions)", DIM)
|
||
)
|
||
|
||
print()
|
||
print_separator("═")
|
||
print(colorize(" Letzte Einträge im Dev Log", BOLD, MAGENTA))
|
||
print_separator("═")
|
||
|
||
entries = get_last_devlog_entries(5)
|
||
if not entries:
|
||
print(colorize(" Keine Einträge in DEVLOG.md.", DIM))
|
||
else:
|
||
for entry in entries:
|
||
lines = entry.splitlines()
|
||
if not lines:
|
||
continue
|
||
# Header line (## date time – time (dur))
|
||
header = lines[0]
|
||
print()
|
||
print(colorize(f" {header}", BOLD, GREEN))
|
||
# Print remaining lines, indented, max 8 lines to keep it compact
|
||
body_lines = [l for l in lines[1:] if l.strip()][:8]
|
||
for bl in body_lines:
|
||
# Dim section headers, normal for bullets
|
||
if bl.startswith("###"):
|
||
print(colorize(f" {bl}", DIM))
|
||
elif bl.startswith("-"):
|
||
print(f" {colorize(bl, WHITE)}")
|
||
elif bl.startswith("**"):
|
||
print(f" {colorize(bl, CYAN)}")
|
||
else:
|
||
print(f" {colorize(bl, DIM)}")
|
||
|
||
print()
|
||
print_separator()
|
||
print()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
print_weekly_report()
|
||
except Exception as e:
|
||
print(f"Fehler: {e}")
|