from collections import defaultdict from datetime import date, datetime, timedelta import re def parse_time_to_minutes(value: str) -> int: if not re.fullmatch(r"([01]\d|2[0-3]):[0-5]\d", value): raise ValueError("Uhrzeit muss im Format HH:MM sein") try: parsed = datetime.strptime(value, "%H:%M") except ValueError as exc: raise ValueError("Uhrzeit muss im Format HH:MM sein") from exc return parsed.hour * 60 + parsed.minute def minutes_to_hhmm(minutes: int) -> str: sign = "-" if minutes < 0 else "" minutes_abs = abs(minutes) hours = minutes_abs // 60 mins = minutes_abs % 60 return f"{sign}{hours:02d}:{mins:02d}" def validate_entry(start_minutes: int, end_minutes: int, break_minutes: int) -> None: if end_minutes <= start_minutes: raise ValueError("Arbeitsende muss nach Arbeitsbeginn liegen") if break_minutes < 0: raise ValueError("Pause darf nicht negativ sein") gross_minutes = end_minutes - start_minutes if break_minutes > gross_minutes: raise ValueError("Pause darf nicht laenger als die Arbeitszeit sein") def required_break_minutes_for_span(work_span_minutes: int) -> int: if work_span_minutes > 9 * 60: return 45 if work_span_minutes > 6 * 60: return 30 return 0 def automatic_break_minutes(start_minutes: int, end_minutes: int) -> int: if end_minutes <= start_minutes: raise ValueError("Arbeitsende muss nach Arbeitsbeginn liegen") return required_break_minutes_for_span(end_minutes - start_minutes) def automatic_break_minutes_for_net_minutes(net_minutes: int) -> int: if net_minutes < 0: raise ValueError("Nettoarbeitszeit darf nicht negativ sein") if net_minutes > (9 * 60 - 45): return 45 if net_minutes > (6 * 60 - 30): return 30 return 0 def compute_net_minutes(start_minutes: int, end_minutes: int, break_minutes: int) -> int: validate_entry(start_minutes, end_minutes, break_minutes) return (end_minutes - start_minutes) - break_minutes def iso_week_bounds(day: date) -> tuple[date, date]: week_start = day - timedelta(days=day.weekday()) week_end = week_start + timedelta(days=6) return week_start, week_end def daterange(start: date, end: date): current = start while current <= end: yield current current += timedelta(days=1) def aggregate_week(entries: list, week_start: date, weekly_target_minutes: int) -> dict: week_end = week_start + timedelta(days=6) entries_by_date = {entry.date: entry for entry in entries} days = [] weekly_ist = 0 for day in daterange(week_start, week_end): entry = entries_by_date.get(day) if entry is None: days.append({"date": day, "entry": None, "net_minutes": 0}) continue net_minutes = compute_net_minutes(entry.start_minutes, entry.end_minutes, entry.break_minutes) weekly_ist += net_minutes days.append({"date": day, "entry": entry, "net_minutes": net_minutes}) weekly_delta = weekly_ist - weekly_target_minutes return { "week_start": week_start, "week_end": week_end, "days": days, "weekly_ist": weekly_ist, "weekly_soll": weekly_target_minutes, "weekly_delta": weekly_delta, } def cumulative_delta(entries: list, selected_week_start: date, weekly_target_minutes: int) -> int: if not entries: return 0 earliest_entry_date = min(entry.date for entry in entries) current_week_start = earliest_entry_date - timedelta(days=earliest_entry_date.weekday()) net_by_week_start = defaultdict(int) for entry in entries: week_start, _ = iso_week_bounds(entry.date) net_by_week_start[week_start] += compute_net_minutes( entry.start_minutes, entry.end_minutes, entry.break_minutes ) running = 0 while current_week_start <= selected_week_start: weekly_ist = net_by_week_start[current_week_start] running += weekly_ist - weekly_target_minutes current_week_start += timedelta(days=7) return running