Create tagerechner_win_v0.12.py

This commit is contained in:
2025-11-21 10:31:12 +01:00
parent de85f4e30d
commit 095564bef1

View File

@@ -0,0 +1,454 @@
# tagerechner_win_v0_13.py
# Zweck: Termine (täglich / wöchentlich / monatlich / quartalsweise) generieren
#
# v0.13 Bugfix: Feiertags-Logik greift jetzt GLOBAL
# - Fehler behoben, bei dem wöchentliche Termine trotz Checkbox auf Feiertage fielen.
# - Wenn "Feiertage beachten" aktiv ist, werden AUCH feste Termine (z.B. "Jeden Freitag")
# auf den nächsten Werktag verschoben, falls sie auf einen Feiertag fallen.
#
# v0.12 GUI Overhaul (2 Spalten)
VERSION = "v0.12 (Beta)"
import re
import calendar
from datetime import datetime, timedelta, date
import tkinter as tk
from tkinter import ttk
try:
import pyperclip
HAS_PYPERCLIP = True
except Exception:
HAS_PYPERCLIP = False
# ---------- Hilfsfunktionen: Feiertage ----------
def calculate_easter_sunday(year: int) -> date:
a = year % 19
b = year // 100
c = year % 100
d = b // 4
e = b % 4
f = (b + 8) // 25
g = (b - f + 1) // 3
h = (19 * a + b - d - g + 15) % 30
i = c // 4
k = c % 4
l = (32 + 2 * e + 2 * i - h - k) % 7
m = (a + 11 * h + 22 * l) // 451
month = (h + l - 7 * m + 114) // 31
day = ((h + l - 7 * m + 114) % 31) + 1
return date(year, month, day)
def is_german_holiday(d: date) -> bool:
year = d.year
fixed_holidays = [(1, 1), (5, 1), (10, 3), (12, 25), (12, 26)]
if (d.month, d.day) in fixed_holidays:
return True
easter_sun = calculate_easter_sunday(year)
# Karfreitag (-2), Ostermontag (+1), Himmelfahrt (+39), Pfingstmontag (+50)
offsets = [-2, 1, 39, 50]
# offsets.append(60) # Optional: Fronleichnam
for off in offsets:
if d == easter_sun + timedelta(days=off):
return True
return False
# ---------- Hilfsfunktionen: Format & Logik ----------
def validate_time_format(time_str: str) -> str:
if not re.match(r'^\d{2}:\d{2}$', time_str):
raise ValueError("Uhrzeit bitte als HH:MM angeben.")
h, m = map(int, time_str.split(":"))
if not (0 <= h < 24 and 0 <= m < 60):
raise ValueError("Uhrzeit ungültig.")
return f"{h:02d}:{m:02d}:00"
def nth_weekday_in_month(year: int, month: int, n: int, weekday0: int):
first_weekday, days_in_month = calendar.monthrange(year, month)
delta = (weekday0 - first_weekday) % 7
day = 1 + delta + 7 * (n - 1)
if day > days_in_month: return None
return date(year, month, day)
def format_full_output(d: date, time_hhmmss: str) -> str:
w_names = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
return f"{w_names[d.weekday()]}. {d.strftime('%d.%m.%Y')} {time_hhmmss}"
def format_clean_output(d: date, time_hhmmss: str) -> str:
return f"{d.strftime('%d.%m.%Y')} {time_hhmmss}"
def iso_week_number(d: date) -> int:
iso = d.isocalendar()
return getattr(iso, "week", iso[1])
def week_parity_ok(d: date, parity: str | None) -> bool:
if parity is None: return True
w = iso_week_number(d)
return (w % 2) == 1 if parity == 'odd' else (w % 2) == 0
def next_workday(d: date, check_holidays: bool) -> date:
"""Verschiebt Datum auf den nächsten Werktag (Mo-Fr), der kein Feiertag ist."""
while d.weekday() >= 5 or (check_holidays and is_german_holiday(d)):
d += timedelta(days=1)
return d
def ensure_valid_date(d: date, check_holidays: bool) -> date:
"""
Wrapper: Prüft, ob d zulässig ist.
Wenn check_holidays=True und d ein Feiertag ist -> Verschieben auf nächsten Werktag.
(Wochenenden bleiben bei festen Terminen oft erhalten, außer es ist explizit next_workday gewünscht.
Aber Feiertage sollten bei 'check_holidays' verschoben werden.)
"""
if check_holidays and is_german_holiday(d):
return next_workday(d, check_holidays=True)
return d
# ---------- Kernlogik ----------
def generate_dates(year: int, frequency: str, time_hhmmss: str,
check_holidays: bool,
weekday_for_weekly=None, weekly_iso_week_parity=None,
monthly_option=None, day_of_month=None, week_in_month=None,
weekday_for_monthly=None, target_weekday_for_monthly=None,
relative_week_offset=0, monthly_iso_week_parity=None,
quarterly_option=None) -> list[tuple]:
start_date = date(year, 1, 1)
end_date = date(year, 12, 31)
raw_dates = []
if frequency == 't':
cur = start_date
while cur <= end_date:
# Auch täglich sollte an Feiertagen springen, wenn gewünscht?
# Interpretation: Täglich ist täglich. Aber wenn check_holidays an ist, skippen wir Feiertage?
# Konsistenter: Wir verschieben nicht (sonst Doppelung), wir skippen bei 't'?
# Change Management Logik: Tägliche Meetings fallen an Feiertagen aus.
if not (check_holidays and is_german_holiday(cur)):
raw_dates.append(cur)
cur += timedelta(days=1)
elif frequency == 'w':
wk = (weekday_for_weekly - 1) % 7
delta = (wk - start_date.weekday()) % 7
cur = start_date + timedelta(days=delta)
# Initiale Paritäts-Suche
if weekly_iso_week_parity in ('odd', 'even'):
while cur <= end_date and not week_parity_ok(cur, weekly_iso_week_parity):
cur += timedelta(days=7)
step = 14
else:
step = 7
while cur <= end_date:
if week_parity_ok(cur, weekly_iso_week_parity):
# NEU: Hier prüfen wir auf Feiertag und verschieben ggf.
actual_date = ensure_valid_date(cur, check_holidays)
raw_dates.append(actual_date)
cur += timedelta(days=step)
elif frequency == 'm':
cur = start_date.replace(day=1)
while cur <= end_date:
d = None
if monthly_option == 1: # Fester Tag
days_in_month = calendar.monthrange(cur.year, cur.month)[1]
if 1 <= day_of_month <= days_in_month:
d = date(cur.year, cur.month, day_of_month)
# NEU: Check
d = ensure_valid_date(d, check_holidays)
elif monthly_option == 2: # Wiederkehrender Wochentag
wk2 = (weekday_for_monthly - 1) % 7
d = nth_weekday_in_month(cur.year, cur.month, week_in_month, wk2)
if d and not week_parity_ok(d, monthly_iso_week_parity): d = None
# NEU: Check
if d: d = ensure_valid_date(d, check_holidays)
elif monthly_option == 3: # Kalendertag -> Werktag (hatte Logik schon, aber sicherheitshalber)
days_in_month = calendar.monthrange(cur.year, cur.month)[1]
if 1 <= day_of_month <= days_in_month:
d = date(cur.year, cur.month, day_of_month)
d = next_workday(d, check_holidays) # Hier war es schon korrekt
if d.month != cur.month: d = None
elif monthly_option == 4: # 1. Werktag
d = first_workday_of_month(cur.year, cur.month, check_holidays) # Hier war es schon korrekt
elif monthly_option == 5: # Relativ
base_wk = (weekday_for_monthly - 1) % 7
target_wk = (target_weekday_for_monthly - 1) % 7
ref = nth_weekday_in_month(cur.year, cur.month, week_in_month, base_wk)
if ref:
delta_days = (target_wk - base_wk) % 7
d = ref + timedelta(days=delta_days + 7 * relative_week_offset)
if d.month != cur.month: d = None
# NEU: Check
if d: d = ensure_valid_date(d, check_holidays)
if d and d <= end_date:
raw_dates.append(d)
if cur.month == 12: cur = date(cur.year + 1, 1, 1)
else: cur = date(cur.year, cur.month + 1, 1)
elif frequency == 'q':
quarters = [3, 6, 9, 12]
for m in quarters:
days_in_month = calendar.monthrange(year, m)[1]
d = None
if quarterly_option == 1:
d = date(year, m, days_in_month)
elif quarterly_option == 2:
day = min(day_of_month, days_in_month)
d = date(year, m, day)
# NEU: Auch Quartals-Termine verschieben bei Feiertag
if d:
d = ensure_valid_date(d, check_holidays)
raw_dates.append(d)
# Formatierung
final_list = []
# Sortieren zur Sicherheit, falls durch Verschiebungen was durcheinander gerät (z.B. Fr -> Mo, aber Mo Termin existiert)
raw_dates.sort()
for d in raw_dates:
disp = format_full_output(d, time_hhmmss)
clip = format_clean_output(d, time_hhmmss)
final_list.append((disp, clip))
return final_list
# ---------- GUI ----------
class App(tk.Tk):
def __init__(self):
super().__init__()
self.title(f"Termin-Generator Change-Planung {VERSION}")
self.geometry("1050x650")
self.minsize(900, 600)
self.left_frame = ttk.Frame(self, padding="10")
self.left_frame.grid(row=0, column=0, sticky="nsew")
self.right_frame = ttk.LabelFrame(self, text="Ergebnis", padding="10")
self.right_frame.grid(row=0, column=1, sticky="nsew", padx=(0, 10), pady=10)
self.grid_columnconfigure(1, weight=1)
self.grid_rowconfigure(0, weight=1)
# LINKS
self.general_frame = ttk.LabelFrame(self.left_frame, text="Allgemeines", padding="10")
self.general_frame.pack(fill="x", pady=(0, 10))
ttk.Label(self.general_frame, text="Jahr:").grid(row=0, column=0, sticky="w", pady=5)
self.year_var = tk.IntVar(value=datetime.now().year)
ttk.Spinbox(self.general_frame, from_=2020, to=2030, textvariable=self.year_var, width=6).grid(row=0, column=1, sticky="w", padx=5)
self.check_holidays_var = tk.BooleanVar(value=True)
ttk.Checkbutton(self.general_frame, text="🇩🇪 Feiertage beachten", variable=self.check_holidays_var).grid(row=0, column=2, sticky="w", padx=15)
ttk.Label(self.general_frame, text="Uhrzeit:").grid(row=1, column=0, sticky="w", pady=5)
self.time_var = tk.StringVar(value="00:00")
ttk.Entry(self.general_frame, textvariable=self.time_var, width=8).grid(row=1, column=1, sticky="w", padx=5)
ttk.Label(self.general_frame, text="Turnus:").grid(row=2, column=0, sticky="w", pady=5)
self.frequency_var = tk.StringVar(value="📆 Monatlich")
freq_cb = ttk.Combobox(self.general_frame, textvariable=self.frequency_var, state='readonly', width=35,
values=["🔁 Täglich", "📅 Wöchentlich", "📆 Monatlich", "🏣 Quartalsweise"])
freq_cb.grid(row=2, column=1, columnspan=2, sticky="w", padx=5)
freq_cb.bind('<<ComboboxSelected>>', self._toggle_by_frequency)
self.dynamic_container = ttk.Frame(self.left_frame)
self.dynamic_container.pack(fill="both", expand=True)
# Weekly
self.weekly_frame = ttk.LabelFrame(self.dynamic_container, text="Wöchentliche Optionen", padding="10")
ttk.Label(self.weekly_frame, text="Wochentag:").grid(row=0, column=0, sticky="w", pady=5)
self.weekly_weekday_var = tk.IntVar(value=1)
self.wd_combo = ttk.Combobox(self.weekly_frame, textvariable=self.weekly_weekday_var, state="readonly", width=15,
values=["1 (Mo)", "2 (Di)", "3 (Mi)", "4 (Do)", "5 (Fr)", "6 (Sa)", "7 (So)"])
self.wd_combo.current(0)
self.wd_combo.grid(row=0, column=1, sticky="w", padx=5)
ttk.Label(self.weekly_frame, text="Filter:").grid(row=1, column=0, sticky="w", pady=5)
self.weekly_iso_kw_var = tk.StringVar(value='Keine Einschränkung')
ttk.Combobox(self.weekly_frame, textvariable=self.weekly_iso_kw_var, state="readonly", width=25,
values=['Keine Einschränkung', 'Nur ungerade KW', 'Nur gerade KW']).grid(row=1, column=1, sticky="w", padx=5)
# Monthly
self.monthly_frame = ttk.LabelFrame(self.dynamic_container, text="Monatliche Optionen", padding="10")
self.monthly_option_var = tk.IntVar(value=2)
opts = [(1, "Fester Kalendertag"), (2, "Wiederkehrender Wochentag"), (3, "Kalendertag (Werktag-Verschiebung)"), (4, "Erster Werktag des Monats"), (5, "Relativ zu anderem Datum")]
for val, txt in opts:
ttk.Radiobutton(self.monthly_frame, text=txt, variable=self.monthly_option_var, value=val, command=self._toggle_monthly).pack(anchor="w", pady=2)
self.monthly_params_frame = ttk.Frame(self.monthly_frame, padding=(0,10))
self.monthly_params_frame.pack(fill="x")
self.lbl_dom = ttk.Label(self.monthly_params_frame, text="Tag (1-31):")
self.spin_dom = ttk.Spinbox(self.monthly_params_frame, from_=1, to=31, width=5)
self.lbl_wim = ttk.Label(self.monthly_params_frame, text="Vorkommen (1-5):")
self.spin_wim = ttk.Spinbox(self.monthly_params_frame, from_=1, to=5, width=5)
self.lbl_wd = ttk.Label(self.monthly_params_frame, text="Wochentag (1-7):")
self.spin_wd = ttk.Spinbox(self.monthly_params_frame, from_=1, to=7, width=5)
self.lbl_twd = ttk.Label(self.monthly_params_frame, text="Ziel-Tag (1-7):")
self.spin_twd = ttk.Spinbox(self.monthly_params_frame, from_=1, to=7, width=5)
self.lbl_off = ttk.Label(self.monthly_params_frame, text="Offset (Wochen):")
self.spin_off = ttk.Spinbox(self.monthly_params_frame, from_=-4, to=4, width=5)
self.spin_off.set(0)
self.lbl_kw = ttk.Label(self.monthly_params_frame, text="KW-Filter:")
self.cb_kw = ttk.Combobox(self.monthly_params_frame, values=['Kein', 'Ungerade', 'Gerade'], state="readonly", width=10)
self.cb_kw.current(0)
# Quarterly
self.quarterly_frame = ttk.LabelFrame(self.dynamic_container, text="Quartals-Optionen", padding="10")
self.quarterly_option_var = tk.IntVar(value=1)
ttk.Radiobutton(self.quarterly_frame, text="Letzter Tag des Quartals", variable=self.quarterly_option_var, value=1, command=self._toggle_quarterly).pack(anchor="w")
ttk.Radiobutton(self.quarterly_frame, text="Fester Tag im Quartalsmonat", variable=self.quarterly_option_var, value=2, command=self._toggle_quarterly).pack(anchor="w")
self.quart_day_frame = ttk.Frame(self.quarterly_frame)
self.quart_day_frame.pack(fill="x", pady=5)
ttk.Label(self.quart_day_frame, text="Tag:").pack(side="left")
self.spin_q_dom = ttk.Spinbox(self.quart_day_frame, from_=1, to=31, width=5)
self.spin_q_dom.pack(side="left", padx=5)
self.spin_q_dom.set(15)
self.preview_var = tk.StringVar(value="...")
ttk.Label(self.left_frame, textvariable=self.preview_var, foreground="blue", wraplength=300).pack(side="bottom", fill="x", pady=10)
# RECHTS
btn_frame = ttk.Frame(self.right_frame)
btn_frame.pack(fill="x", pady=(0, 10))
self.run_btn = ttk.Button(btn_frame, text="🚀 Berechnen & Kopieren", command=self.on_run)
self.run_btn.pack(side="left", fill="x", expand=True, padx=(0, 5))
self.clear_btn = ttk.Button(btn_frame, text="🧹", width=3, command=self.on_clear)
self.clear_btn.pack(side="right")
list_container = ttk.Frame(self.right_frame)
list_container.pack(fill="both", expand=True)
self.scrollbar = ttk.Scrollbar(list_container, orient="vertical")
self.result_list = tk.Listbox(list_container, yscrollcommand=self.scrollbar.set, font=("Consolas", 10))
self.scrollbar.config(command=self.result_list.yview)
self.scrollbar.pack(side="right", fill="y")
self.result_list.pack(side="left", fill="both", expand=True)
self.status_var = tk.StringVar(value="Bereit.")
self.status_bar = ttk.Label(self, textvariable=self.status_var, relief="sunken", anchor="w", padding=(5, 2))
self.status_bar.grid(row=1, column=0, columnspan=2, sticky="ew")
self._toggle_by_frequency(None)
self._toggle_monthly()
self._toggle_quarterly()
def _get_frequency_code(self) -> str:
val = self.frequency_var.get()
if "Täglich" in val: return 't'
if "Wöchentlich" in val: return 'w'
if "Monatlich" in val: return 'm'
if "Quartals" in val: return 'q'
return 't'
def _toggle_by_frequency(self, _evt):
f = self._get_frequency_code()
self.weekly_frame.pack_forget()
self.monthly_frame.pack_forget()
self.quarterly_frame.pack_forget()
if f == 'w': self.weekly_frame.pack(fill="x", pady=10)
elif f == 'm': self.monthly_frame.pack(fill="x", pady=10)
elif f == 'q': self.quarterly_frame.pack(fill="x", pady=10)
self._update_preview()
def _clear_monthly_params(self):
for widget in self.monthly_params_frame.winfo_children(): widget.grid_forget()
def _toggle_monthly(self):
self._clear_monthly_params()
opt = self.monthly_option_var.get()
if opt in (1, 3):
self.lbl_dom.grid(row=0, column=0, padx=5); self.spin_dom.grid(row=0, column=1, padx=5)
elif opt == 2:
self.lbl_wim.grid(row=0, column=0); self.spin_wim.grid(row=0, column=1)
self.lbl_wd.grid(row=0, column=2); self.spin_wd.grid(row=0, column=3)
self.lbl_kw.grid(row=1, column=0); self.cb_kw.grid(row=1, column=1)
elif opt == 5:
self.lbl_wim.grid(row=0, column=0); self.spin_wim.grid(row=0, column=1)
self.lbl_wd.grid(row=0, column=2); self.spin_wd.grid(row=0, column=3)
self.lbl_twd.grid(row=1, column=0); self.spin_twd.grid(row=1, column=1)
self.lbl_off.grid(row=1, column=2); self.spin_off.grid(row=1, column=3)
self._update_preview()
def _toggle_quarterly(self):
if self.quarterly_option_var.get() == 2:
for child in self.quart_day_frame.winfo_children(): child.configure(state='normal')
else:
for child in self.quart_day_frame.winfo_children(): child.configure(state='disabled')
self._update_preview()
def _update_preview(self):
self.preview_var.set(f"Modus: {self.frequency_var.get()}")
def on_run(self):
try:
self.status_var.set("Berechne...")
self.update_idletasks()
year = self.year_var.get()
freq = self._get_frequency_code()
time_s = validate_time_format(self.time_var.get())
check_holidays = self.check_holidays_var.get()
wd_weekly = int(self.wd_combo.get().split()[0])
kw_par = None
if "ungerade" in self.weekly_iso_kw_var.get().lower(): kw_par = 'odd'
elif "gerade" in self.weekly_iso_kw_var.get().lower(): kw_par = 'even'
m_opt = self.monthly_option_var.get()
dom = int(self.spin_dom.get() or 1)
wim = int(self.spin_wim.get() or 1)
wd_monthly = int(self.spin_wd.get() or 1)
twd = int(self.spin_twd.get() or 1)
off = int(self.spin_off.get() or 0)
m_kw_par = None
if "Ungerade" in self.cb_kw.get(): m_kw_par = 'odd'
elif "Gerade" in self.cb_kw.get(): m_kw_par = 'even'
q_opt = self.quarterly_option_var.get()
q_dom = int(self.spin_q_dom.get() or 1)
results = generate_dates(
year, freq, time_s, check_holidays,
weekday_for_weekly=wd_weekly, weekly_iso_week_parity=kw_par,
monthly_option=m_opt, day_of_month=dom, week_in_month=wim,
weekday_for_monthly=wd_monthly, target_weekday_for_monthly=twd,
relative_week_offset=off, monthly_iso_week_parity=m_kw_par,
quarterly_option=q_opt if freq == 'q' else None
)
if freq == 'q' and q_opt == 2:
results = generate_dates(year, freq, time_s, check_holidays, quarterly_option=q_opt, day_of_month=q_dom)
self.result_list.delete(0, tk.END)
clipboard_text = []
for display_str, clip_str in results:
self.result_list.insert(tk.END, display_str)
clipboard_text.append(clip_str)
if HAS_PYPERCLIP:
pyperclip.copy("\n".join(clipboard_text))
self.status_var.set(f"{len(results)} Termine generiert & kopiert!")
else:
self.status_var.set(f"{len(results)} Termine generiert (Kein Clipboard-Modul).")
except Exception as e:
self.status_var.set(f"❌ Fehler: {e}")
def on_clear(self):
self.result_list.delete(0, tk.END)
self.status_var.set("Liste geleert.")
if __name__ == "__main__":
App().mainloop()