diff --git a/CP Tagesrechner Jahresplanung/readme.md b/CP Tagesrechner Jahresplanung/readme.md new file mode 100644 index 0000000..e32cc20 --- /dev/null +++ b/CP Tagesrechner Jahresplanung/readme.md @@ -0,0 +1,48 @@ +# đŸ—“ïž Termin-Generator fĂŒr Change-Planung +**Version:** v0.10 +**Autor:** Mike Lindner + +--- + +## 📌 Übersicht + +Der Termin-Generator ist ein Desktop-Tool fĂŒr Windows, das automatisiert wiederkehrende Termine fĂŒr ein vollstĂ€ndiges Kalenderjahr erzeugt. +Es wurde speziell fĂŒr **IT-Change-Management**, **Wartungsfenster-Planung** und **regulatorische IT-Rhythmen** entwickelt. + +Typische Beispiele: + +- „Jeden zweiten Mittwoch im Monat um 19:00 Uhr“ +- „Dienstag in der Woche nach dem zweiten Dienstag“ +- „15. oder nĂ€chster Werktag, falls Wochenende“ +- „Nur in ungeraden ISO-Kalenderwochen“ +- „Letzter Kalendertag des Quartals“ +- „Monatlicher Samstag nach dem dritten Donnerstag“ + +Die erzeugten Termine können direkt weiterverarbeitet werden (Excel, Jira, Confluence, Planner, ServiceNow, Outlook etc.). + +--- + +## 🚀 Funktionen + +| Funktion | Status | +|----------|--------| +| Wiederkehrende Termine (tĂ€glich, wöchentlich, monatlich, quartalsweise) | ✔ | +| Berechnung kompletter Serien fĂŒr 1 Jahr | ✔ | +| ISO-Kalenderwochen-Filter (ungerade / gerade) | ✔ | +| Monatsregeln (z. B. „2. Mittwoch“, „Fester Tag“, „Folgetag bei Wochenende“) | ✔ | +| Relative Terminlogik („Mittwoch nach 2. Dienstag“, „+1 Woche“) | ✔ | +| Kopieren der erzeugten Serien in die Zwischenablage | ✔ | +| BedienoberflĂ€che mit selbsterklĂ€renden Beschriftungen und Emojis | ✔ | +| Quartalsfunktionen: letzter Tag im Quartal oder frei definierbar | ✔ | + +--- + +## đŸ–„ïž Systemvoraussetzungen + +- Windows mit **Python 3.x** +- Kein Administrator-Zugriff notwendig + +Optional (fĂŒr automatische Zwischenablage-Kopie): + +```bash +pip install pyperclip diff --git a/CP Tagesrechner Jahresplanung/tagerechner_win_v0.10.py b/CP Tagesrechner Jahresplanung/tagerechner_win_v0.10.py new file mode 100644 index 0000000..51e0745 --- /dev/null +++ b/CP Tagesrechner Jahresplanung/tagerechner_win_v0.10.py @@ -0,0 +1,848 @@ +# tagerechner_win_v0_10.py +# Zweck: Termine (tĂ€glich / wöchentlich / monatlich / quartalsweise) eines Jahres generieren +# v0.10 – Quartalsweise erweitert: +# - Quartalsweise: +# - Option 1: letzter Kalendertag des Quartals +# - Option 2: frei wĂ€hlbarer Kalendertag im Quartalsmonat (z. B. 15.) +# +# v0.09 – UI-Refactor (deutsch, mit Emojis, besser verstĂ€ndliche Bezeichnungen) +# +# Logik: +# - tĂ€glich: jeder Kalendertag +# - wöchentlich: bestimmter Wochentag, optional nur ungerade/gerade Kalenderwochen +# - monatlich: +# 1) 📅 Fester Kalendertag +# 2) 📆 Wiederkehrender Wochentag (z. B. 2. Mittwoch) +# 3) ➡ Kalendertag mit Verschiebung auf nĂ€chsten Werktag (Mo–Fr) +# 4) 🏱 Erster Werktag des Monats (Mo–Fr) +# 5) â†Ș Termin abhĂ€ngig von anderem Datum (relativer Wochentag, mit Wochen-Offset) +# - quartalsweise: +# 1) 🏣 Letzter Kalendertag des Quartals +# 2) 📅 Fester Kalendertag im Quartalsmonat (z. B. 15. im MĂ€rz/Juni/Sept./Dez.) + +VERSION = "v0.10" + +import re +import calendar +from datetime import datetime, timedelta, date + +import tkinter as tk +from tkinter import ttk, messagebox + +try: + import pyperclip + HAS_PYPERCLIP = True +except Exception: + HAS_PYPERCLIP = False + + +# ---------- Hilfsfunktionen ---------- + +def validate_time_format(time_str: str) -> str: + """ + Erwartet HH:MM. Liefert normalisiert HH:MM:SS (Sekunden = :00). + """ + 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 (Stunden 0–23, Minuten 0–59).") + return f"{h:02d}:{m:02d}:00" + + +def nth_weekday_in_month(year: int, month: int, n: int, weekday0: int): + """ + Liefert das Datum des n-ten weekday0 (0=Mo..6=So) im Monat. + Gibt None zurĂŒck, wenn es dieses n-te Vorkommen in diesem Monat nicht gibt. + """ + first_weekday, days_in_month = calendar.monthrange(year, month) # first_weekday: 0=Mo..6=So + 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_datetime_for_output(d: date, time_hhmmss: str) -> str: + return d.strftime("%d.%m.%Y ") + time_hhmmss + + +def iso_week_number(d: date) -> int: + """ISO-Kalenderwoche (1..53).""" + iso = d.isocalendar() + return getattr(iso, "week", iso[1]) + + +def week_parity_ok(d: date, parity: str | None) -> bool: + """ + parity: None | 'odd' | 'even' + """ + if parity is None: + return True + w = iso_week_number(d) + if parity == 'odd': + return (w % 2) == 1 + if parity == 'even': + return (w % 2) == 0 + return True + + +def first_workday_of_month(year: int, month: int) -> date: + """ + 1. Werktag (Mo–Fr) des Monats. + (Feiertage werden NICHT berĂŒcksichtigt.) + """ + d = date(year, month, 1) # 1. des Monats + wd = d.weekday() # Mo=0 .. So=6 + if wd >= 5: # Sa(5) -> +2, So(6) -> +1 + d += timedelta(days=(7 - wd)) + return d + + +def next_workday(d: date) -> date: + """ + NĂ€chster Werktag (Mo–Fr) ab d, inkl. d selbst wenn d ein Werktag ist. + (Feiertage werden NICHT berĂŒcksichtigt.) + """ + while d.weekday() >= 5: # Sa(5), So(6) + d += timedelta(days=1) + return d + + +def weekday_name_de(weekday1: int) -> str: + """Wochentag 1..7 -> deutscher Name.""" + names = { + 1: "Montag", + 2: "Dienstag", + 3: "Mittwoch", + 4: "Donnerstag", + 5: "Freitag", + 6: "Samstag", + 7: "Sonntag", + } + return names.get(weekday1, f"Wochentag {weekday1}") + + +def ordinal_de(n: int) -> str: + """1 -> '1.', 2 -> '2.' etc.""" + return f"{n}." + + +# ---------- Kernlogik ---------- + +def generate_dates(year: int, + frequency: str, + time_hhmmss: str, + weekday_for_weekly: int | None = None, + weekly_iso_week_parity: str | None = None, + monthly_option: int | None = None, + day_of_month: int | None = None, + week_in_month: int | None = None, + weekday_for_monthly: int | None = None, + target_weekday_for_monthly: int | None = None, + relative_week_offset: int = 0, + monthly_iso_week_parity: str | None = None, + quarterly_option: int | None = None) -> list[str]: + """ + frequency: 't' (tĂ€glich), 'w' (wöchentlich), 'm' (monatlich), 'q' (quartalsweise) + weekday_*: 1=Mo .. 7=So (Benutzer-Sicht). Intern 0..6. + monthly_option: + 1 = 📅 Fester Kalendertag + 2 = 📆 Wiederkehrender Wochentag (+ optionaler ISO-KW-Filter) + 3 = ➡ Kalendertag mit Verschiebung auf nĂ€chsten Werktag (Mo–Fr) + 4 = 🏱 Erster Werktag des Monats (Mo–Fr) + 5 = â†Ș Termin abhĂ€ngig von anderem Datum (relativer Wochentag, mit Wochen-Offset) + quarterly_option: + 1 = 🏣 Letzter Kalendertag des Quartals + 2 = 📅 Fester Kalendertag im Quartalsmonat (z. B. 15.) + """ + start_date = date(year, 1, 1) + end_date = date(year, 12, 31) + out = [] + + if frequency == 't': + cur = start_date + while cur <= end_date: + out.append(format_datetime_for_output(cur, time_hhmmss)) + cur += timedelta(days=1) + + elif frequency == 'w': + if weekday_for_weekly is None: + raise ValueError("Bitte einen Wochentag fĂŒr wöchentliche Termine auswĂ€hlen.") + wk = (weekday_for_weekly - 1) % 7 # 0..6 + # erstes Auftreten im Jahr + delta = (wk - start_date.weekday()) % 7 + cur = start_date + timedelta(days=delta) + + # Falls ISO-ParitĂ€t gefordert: auf erste passende Woche vorrĂŒcken + 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 # ParitĂ€t bleibt bei +14 erhalten + else: + step = 7 + + while cur <= end_date: + if week_parity_ok(cur, weekly_iso_week_parity): + out.append(format_datetime_for_output(cur, time_hhmmss)) + cur += timedelta(days=step) + + elif frequency == 'm': + cur = start_date.replace(day=1) + while cur <= end_date: + if monthly_option == 1: + # 📅 Fester Kalendertag + if day_of_month is None: + raise ValueError("Bitte 'Tag im Monat' angeben (1..31).") + 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) + if d <= end_date: + out.append(format_datetime_for_output(d, time_hhmmss)) + + elif monthly_option == 2: + # 📆 Wiederkehrender Wochentag + optionaler ISO-KW-Filter + if week_in_month is None or weekday_for_monthly is None: + raise ValueError("Bitte Vorkommen (1..5) und Wochentag auswĂ€hlen.") + wk2 = (weekday_for_monthly - 1) % 7 # 0..6 + d = nth_weekday_in_month(cur.year, cur.month, week_in_month, wk2) + if d is not None and d <= end_date: + if week_parity_ok(d, monthly_iso_week_parity): + out.append(format_datetime_for_output(d, time_hhmmss)) + + elif monthly_option == 3: + # ➡ Kalendertag mit Verschiebung auf nĂ€chsten Werktag (Mo–Fr) + if day_of_month is None: + raise ValueError("Bitte 'Kalendertag' angeben (1..31).") + 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) + if d <= end_date and d.month == cur.month: + out.append(format_datetime_for_output(d, time_hhmmss)) + + elif monthly_option == 4: + # 🏱 Erster Werktag im Monat (Mo–Fr) + d = first_workday_of_month(cur.year, cur.month) + if d <= end_date: + out.append(format_datetime_for_output(d, time_hhmmss)) + + elif monthly_option == 5: + # â†Ș Relativer Wochentag: Ziel-Wochentag nach n-tem Referenz-Wochentag + Wochen-Offset + if week_in_month is None or weekday_for_monthly is None or target_weekday_for_monthly is None: + raise ValueError("FĂŒr 'Termin abhĂ€ngig von anderem Datum' bitte Vorkommen, Referenz- und Ziel-Wochentag angeben.") + base_wk = (weekday_for_monthly - 1) % 7 # Referenz-Wochentag (0..6) + target_wk = (target_weekday_for_monthly - 1) % 7 + ref = nth_weekday_in_month(cur.year, cur.month, week_in_month, base_wk) + if ref is not None and ref <= end_date: + # delta innerhalb derselben Woche + Wochen-Offset in ganzen Wochen + delta_days = (target_wk - base_wk) % 7 + d = ref + timedelta(days=delta_days + 7 * relative_week_offset) + if d.month == cur.month and d <= end_date: + out.append(format_datetime_for_output(d, time_hhmmss)) + + else: + raise ValueError("Bitte eine gĂŒltige Monats-Option wĂ€hlen.") + + # nĂ€chster Monat + if cur.month == 12: + cur = date(cur.year + 1, 1, 1) + else: + cur = date(cur.year, cur.month + 1, 1) + + elif frequency == 'q': + # Quartale: letzter Monat je Quartal = 3, 6, 9, 12 + quarters = [3, 6, 9, 12] + + if quarterly_option is None or quarterly_option == 1: + # 🏣 Letzter Kalendertag des Quartals (wie bisher) + for m in quarters: + days_in_month = calendar.monthrange(year, m)[1] + d = date(year, m, days_in_month) + out.append(format_datetime_for_output(d, time_hhmmss)) + elif quarterly_option == 2: + # 📅 Fester Kalendertag im Quartalsmonat (z. B. 15.) + if day_of_month is None: + raise ValueError("Bitte den Kalendertag im Quartalsmonat angeben (1..31).") + for m in quarters: + days_in_month = calendar.monthrange(year, m)[1] + day = min(day_of_month, days_in_month) # 31 in einem 30-Tage-Monat -> letzter Tag + d = date(year, m, day) + out.append(format_datetime_for_output(d, time_hhmmss)) + else: + raise ValueError("Unbekannte Quartals-Option.") + + else: + raise ValueError("Frequenz unbekannt.") + + return out + + +# ---------- GUI ---------- + +class App(tk.Tk): + def __init__(self): + super().__init__() + + self.title(f"Termin-Generator – Change-Planung {VERSION}") + self.geometry("980x780") + + # ---------- Grund-Layout ---------- + + # Jahr + ttk.Label(self, text="Jahr:").grid(row=0, column=0, padx=10, pady=8, sticky="e") + self.year_var = tk.IntVar(value=datetime.now().year) + self.year_spin = ttk.Spinbox(self, from_=1900, to=2100, textvariable=self.year_var, width=8) + self.year_spin.grid(row=0, column=1, padx=10, pady=8, sticky="w") + + # Frequenz (deutsche Bezeichnungen) + ttk.Label(self, text="Wie oft soll der Termin stattfinden?").grid(row=1, column=0, padx=10, pady=8, sticky="e") + self.frequency_var = tk.StringVar(value="📆 Monatlich") + self.frequency_combo = ttk.Combobox( + self, + textvariable=self.frequency_var, + values=[ + "🔁 TĂ€glich", + "📅 Wöchentlich", + "📆 Monatlich", + "🏣 Quartalsweise (letzter Kalendertag oder fester Tag)" + ], + state='readonly', + width=45 + ) + self.frequency_combo.grid(row=1, column=1, padx=10, pady=8, sticky="w") + self.frequency_combo.bind('<>', self._toggle_by_frequency) + + # Uhrzeit + ttk.Label(self, text="⏱ Uhrzeit (HH:MM):").grid(row=2, column=0, padx=10, pady=8, sticky="e") + self.time_var = tk.StringVar(value="00:00") + self.time_entry = ttk.Entry(self, textvariable=self.time_var, width=8) + self.time_entry.grid(row=2, column=1, padx=10, pady=8, sticky="w") + ttk.Label(self, text="Beispiel: 07:00 oder 18:30").grid(row=2, column=1, padx=100, pady=8, sticky="w") + + # ---------- Wöchentlich ---------- + + self.weekly_frame = ttk.LabelFrame(self, text="📅 Wöchentliche Einstellungen") + self.weekly_weekday_label = ttk.Label(self.weekly_frame, text="Wochentag auswĂ€hlen (1=Mo .. 7=So):") + self.weekly_weekday_var = tk.IntVar(value=2) + self.weekly_weekday_spin = ttk.Spinbox(self.weekly_frame, from_=1, to=7, textvariable=self.weekly_weekday_var, width=6) + + self.weekly_iso_kw_label = ttk.Label(self.weekly_frame, text="EinschrĂ€nkung nach Kalenderwochen:") + self.weekly_iso_kw_var = tk.StringVar(value='Keine EinschrĂ€nkung') + self.weekly_iso_kw_combo = ttk.Combobox( + self.weekly_frame, + textvariable=self.weekly_iso_kw_var, + values=[ + 'Keine EinschrĂ€nkung', + 'Nur in ungeraden Kalenderwochen', + 'Nur in geraden Kalenderwochen' + ], + state='readonly', + width=32 + ) + + self.weekly_hint = ttk.Label( + self.weekly_frame, + text="Hinweis: „Nur in ungeraden/geraden Kalenderwochen“ eignet sich fĂŒr 14-tĂ€gige Rhythmen.", + foreground="grey" + ) + + # ---------- Monatlich ---------- + + self.monthly_frame = ttk.LabelFrame(self, text="📆 Monatliche Terminlogik") + + # Options (Reihenfolge: einfach → komplex) + self.monthly_option_var = tk.IntVar(value=2) # Default: Wiederkehrender Wochentag + + self.rb_m_fixed = ttk.Radiobutton( + self.monthly_frame, + text="📅 Fester Kalendertag (z. B. jeden 15.)", + variable=self.monthly_option_var, + value=1, + command=self._toggle_monthly + ) + self.rb_m_nthwd = ttk.Radiobutton( + self.monthly_frame, + text="📆 Wiederkehrender Wochentag (z. B. 2. Mittwoch im Monat)", + variable=self.monthly_option_var, + value=2, + command=self._toggle_monthly + ) + self.rb_m_followingwd = ttk.Radiobutton( + self.monthly_frame, + text="➡ Kalendertag, ggf. verschoben auf nĂ€chsten Werktag (Mo–Fr)", + variable=self.monthly_option_var, + value=3, + command=self._toggle_monthly + ) + self.rb_m_firstwd = ttk.Radiobutton( + self.monthly_frame, + text="🏱 Erster Werktag des Monats (Mo–Fr)", + variable=self.monthly_option_var, + value=4, + command=self._toggle_monthly + ) + self.rb_m_relative = ttk.Radiobutton( + self.monthly_frame, + text="â†Ș Termin abhĂ€ngig von anderem Datum (z. B. Mittwoch nach 2. Dienstag)", + variable=self.monthly_option_var, + value=5, + command=self._toggle_monthly + ) + + # Felder fĂŒr Optionen 1 & 3 (Kalendertag / Kalendertag + Folgewerktag) + self.day_of_month_label = ttk.Label(self.monthly_frame, text="Tag im Monat (1..31):") + self.day_of_month_var = tk.IntVar(value=1) + self.day_of_month_spin = ttk.Spinbox(self.monthly_frame, from_=1, to=31, textvariable=self.day_of_month_var, width=6) + + # Felder fĂŒr Option 2 & 5 (n-ter / relativer Wochentag) + self.week_in_month_label = ttk.Label(self.monthly_frame, text="Vorkommen im Monat (1..5):") + self.week_in_month_var = tk.IntVar(value=2) + self.week_in_month_spin = ttk.Spinbox(self.monthly_frame, from_=1, to=5, textvariable=self.week_in_month_var, width=6) + + self.monthly_weekday_label = ttk.Label(self.monthly_frame, text="Wochentag (1=Mo .. 7=So):") + self.monthly_weekday_var = tk.IntVar(value=3) + self.monthly_weekday_spin = ttk.Spinbox(self.monthly_frame, from_=1, to=7, textvariable=self.monthly_weekday_var, width=6) + + self.monthly_target_weekday_label = ttk.Label(self.monthly_frame, text="Ziel-Wochentag (1=Mo .. 7=So):") + self.monthly_target_weekday_var = tk.IntVar(value=3) + self.monthly_target_weekday_spin = ttk.Spinbox(self.monthly_frame, from_=1, to=7, textvariable=self.monthly_target_weekday_var, width=6) + + self.monthly_week_offset_label = ttk.Label(self.monthly_frame, text="Wochen-Offset (-4 .. +4):") + self.monthly_week_offset_var = tk.IntVar(value=0) + self.monthly_week_offset_spin = ttk.Spinbox(self.monthly_frame, from_=-4, to=4, textvariable=self.monthly_week_offset_var, width=6) + + # ISO-KW-Filter nur fĂŒr Option 2 + self.iso_kw_filter_label = ttk.Label(self.monthly_frame, text="EinschrĂ€nkung nach Kalenderwochen (optional):") + self.iso_kw_filter_var = tk.StringVar(value='Keine EinschrĂ€nkung') + self.iso_kw_filter_combo = ttk.Combobox( + self.monthly_frame, + textvariable=self.iso_kw_filter_var, + values=[ + 'Keine EinschrĂ€nkung', + 'Nur in ungeraden Kalenderwochen', + 'Nur in geraden Kalenderwochen' + ], + state='readonly', + width=32 + ) + + self.monthly_hint = ttk.Label(self.monthly_frame, text="", foreground="grey") + + # ---------- Quartalsweise ---------- + + self.quarterly_frame = ttk.LabelFrame(self, text="🏣 Quartalsweise Einstellungen") + + self.quarterly_option_var = tk.IntVar(value=1) # 1 = letzter Kalendertag, 2 = fester Tag + self.rb_q_last = ttk.Radiobutton( + self.quarterly_frame, + text="🏣 Letzter Kalendertag des Quartals", + variable=self.quarterly_option_var, + value=1, + command=self._toggle_quarterly + ) + self.rb_q_fixed = ttk.Radiobutton( + self.quarterly_frame, + text="📅 Fester Kalendertag im Quartalsmonat (z. B. 15.)", + variable=self.quarterly_option_var, + value=2, + command=self._toggle_quarterly + ) + + self.quarter_day_label = ttk.Label(self.quarterly_frame, text="Kalendertag im Quartalsmonat (1..31):") + self.quarter_day_var = tk.IntVar(value=15) + self.quarter_day_spin = ttk.Spinbox(self.quarterly_frame, from_=1, to=31, textvariable=self.quarter_day_var, width=6) + + self.quarter_hint = ttk.Label( + self.quarterly_frame, + text="Hinweis: Quartalsmonat = MĂ€rz, Juni, September, Dezember. " + "Ist der Tag grĂ¶ĂŸer als die MonatslĂ€nge (z. B. 31.06.), wird der letzte Tag genommen.", + foreground="grey", + wraplength=750, + justify="left" + ) + + # ---------- Buttons & Ergebnis ---------- + + self.run_btn = ttk.Button(self, text="🧼 Berechnen & in Zwischenablage kopieren", command=self.on_run) + self.clear_btn = ttk.Button(self, text="đŸ§č Liste leeren", command=self.on_clear) + + # Regel-Vorschau + self.preview_var = tk.StringVar(value="Regel-Vorschau: (noch nichts berechnet)") + self.preview_label = ttk.Label(self, textvariable=self.preview_var, foreground="blue") + + # Ergebnis + ttk.Label(self, text="Ergebnis:").grid(row=8, column=0, padx=10, pady=(10, 0), sticky="nw") + self.result_list = tk.Listbox(self, height=16, width=70) + self.result_list.grid(row=8, column=1, padx=10, pady=(10, 0), sticky="nsew") + + # Versionslabel + self.version_label = ttk.Label(self, text=f"Version: {VERSION}", foreground="grey") + self.version_label.grid(row=9, column=0, padx=10, pady=(4, 8), sticky="w") + + # Layout-Konfiguration + self.grid_columnconfigure(1, weight=1) + self.grid_rowconfigure(8, weight=1) + + # Frames platzieren + self.weekly_frame.grid(row=3, column=0, columnspan=2, padx=10, pady=8, sticky="ew") + self.monthly_frame.grid(row=4, column=0, columnspan=2, padx=10, pady=8, sticky="ew") + self.quarterly_frame.grid(row=5, column=0, columnspan=2, padx=10, pady=8, sticky="ew") + + # Wöchentlich-Frame Inhalt + self.weekly_weekday_label.grid(row=0, column=0, padx=10, pady=6, sticky="w") + self.weekly_weekday_spin.grid(row=0, column=1, padx=10, pady=6, sticky="w") + self.weekly_iso_kw_label.grid(row=1, column=0, padx=10, pady=6, sticky="w") + self.weekly_iso_kw_combo.grid(row=1, column=1, padx=10, pady=6, sticky="w") + self.weekly_hint.grid(row=2, column=0, columnspan=2, padx=10, pady=4, sticky="w") + + # Monats-Frame Optionen + self.rb_m_fixed.grid(row=0, column=0, columnspan=3, padx=10, pady=(8, 4), sticky="w") + self.rb_m_nthwd.grid(row=1, column=0, columnspan=3, padx=10, pady=4, sticky="w") + self.rb_m_followingwd.grid(row=2, column=0, columnspan=3, padx=10, pady=4, sticky="w") + self.rb_m_firstwd.grid(row=3, column=0, columnspan=3, padx=10, pady=4, sticky="w") + self.rb_m_relative.grid(row=4, column=0, columnspan=3, padx=10, pady=4, sticky="w") + + self.monthly_hint.grid(row=9, column=0, columnspan=3, padx=10, pady=(4, 8), sticky="w") + + # Quartals-Frame Inhalt + self.rb_q_last.grid(row=0, column=0, columnspan=2, padx=10, pady=(8, 4), sticky="w") + self.rb_q_fixed.grid(row=1, column=0, columnspan=2, padx=10, pady=4, sticky="w") + self.quarter_hint.grid(row=3, column=0, columnspan=3, padx=10, pady=(4, 8), sticky="w") + + # Buttons & Vorschau + self.run_btn.grid(row=6, column=1, padx=10, pady=10, sticky="w") + self.clear_btn.grid(row=6, column=1, padx=10, pady=10, sticky="e") + self.preview_label.grid(row=7, column=0, columnspan=2, padx=10, pady=(4, 0), sticky="w") + + # Initial aktiv/inaktiv setzen + self._toggle_by_frequency(None) + self._toggle_monthly() + self._toggle_quarterly() + + # ---------- Helper: Mapping Frequenz ---------- + + def _get_frequency_code(self) -> str: + val = self.frequency_var.get() + if val.startswith("🔁"): + return 't' + if val.startswith("📅"): + return 'w' + if val.startswith("📆"): + return 'm' + if val.startswith("🏣"): + return 'q' + return 't' + + # ---------- UI-Toggles ---------- + + def _toggle_by_frequency(self, _evt): + f = self._get_frequency_code() + + if f == 'w': + self.weekly_frame.grid() + else: + self.weekly_frame.grid_remove() + + if f == 'm': + self.monthly_frame.grid() + else: + self.monthly_frame.grid_remove() + + if f == 'q': + self.quarterly_frame.grid() + else: + self.quarterly_frame.grid_remove() + + def _hide_monthly_fields(self): + for w in ( + self.day_of_month_label, self.day_of_month_spin, + self.week_in_month_label, self.week_in_month_spin, + self.monthly_weekday_label, self.monthly_weekday_spin, + self.monthly_target_weekday_label, self.monthly_target_weekday_spin, + self.monthly_week_offset_label, self.monthly_week_offset_spin, + self.iso_kw_filter_label, self.iso_kw_filter_combo + ): + w.grid_remove() + + def _toggle_monthly(self): + self._hide_monthly_fields() + opt = int(self.monthly_option_var.get()) + hint = "" + + if opt == 1: + # 📅 Fester Kalendertag + self.day_of_month_label.config(text="Tag im Monat (1..31):") + self.day_of_month_label.grid(row=5, column=0, padx=10, pady=6, sticky="w") + self.day_of_month_spin.grid(row=5, column=1, padx=10, pady=6, sticky="w") + hint = "Beispiel: jeden 15. im Monat." + + elif opt == 2: + # 📆 Wiederkehrender Wochentag + self.week_in_month_label.config(text="Vorkommen im Monat (1..5):") + self.monthly_weekday_label.config(text="Wochentag (1=Mo .. 7=So):") + + self.week_in_month_label.grid(row=5, column=0, padx=10, pady=6, sticky="w") + self.week_in_month_spin.grid(row=5, column=1, padx=10, pady=6, sticky="w") + self.monthly_weekday_label.grid(row=6, column=0, padx=10, pady=6, sticky="w") + self.monthly_weekday_spin.grid(row=6, column=1, padx=10, pady=6, sticky="w") + self.iso_kw_filter_label.grid(row=7, column=0, padx=10, pady=6, sticky="w") + self.iso_kw_filter_combo.grid(row=7, column=1, padx=10, pady=6, sticky="w") + hint = "Beispiel: 2. Mittwoch im Monat. Optional auf ungerade/gerade Kalenderwochen einschrĂ€nkbar." + + elif opt == 3: + # ➡ Kalendertag + Folgewerktag + self.day_of_month_label.config(text="Kalendertag (1..31):") + self.day_of_month_label.grid(row=5, column=0, padx=10, pady=6, sticky="w") + self.day_of_month_spin.grid(row=5, column=1, padx=10, pady=6, sticky="w") + hint = "Wenn der gewĂ€hlte Tag auf Samstag/Sonntag fĂ€llt, wird automatisch der nĂ€chste Werktag verwendet." + + elif opt == 4: + # 🏱 Erster Werktag + hint = "Erster Arbeitstag (Mo–Fr) des Monats. FĂ€llt der 1. auf Sa/So, wird auf den folgenden Montag verschoben." + + elif opt == 5: + # â†Ș Termin abhĂ€ngig von anderem Datum + self.week_in_month_label.config(text="Referenz: Vorkommen im Monat (1..5):") + self.monthly_weekday_label.config(text="Referenz: Wochentag (1=Mo .. 7=So):") + + self.week_in_month_label.grid(row=5, column=0, padx=10, pady=6, sticky="w") + self.week_in_month_spin.grid(row=5, column=1, padx=10, pady=6, sticky="w") + self.monthly_weekday_label.grid(row=6, column=0, padx=10, pady=6, sticky="w") + self.monthly_weekday_spin.grid(row=6, column=1, padx=10, pady=6, sticky="w") + self.monthly_target_weekday_label.grid(row=7, column=0, padx=10, pady=6, sticky="w") + self.monthly_target_weekday_spin.grid(row=7, column=1, padx=10, pady=6, sticky="w") + self.monthly_week_offset_label.grid(row=8, column=0, padx=10, pady=6, sticky="w") + self.monthly_week_offset_spin.grid(row=8, column=1, padx=10, pady=6, sticky="w") + hint = ( + "Beispiele: Mittwoch nach dem 2. Dienstag (Offset=0) " + "oder Dienstag in der Woche nach dem 2. Dienstag (Offset=1)." + ) + + self.monthly_hint.config(text=hint) + + def _toggle_quarterly(self): + opt = int(self.quarterly_option_var.get()) + # Standard: Spinner verstecken + self.quarter_day_label.grid_remove() + self.quarter_day_spin.grid_remove() + + if opt == 2: + # Fester Kalendertag im Quartalsmonat + self.quarter_day_label.grid(row=2, column=0, padx=10, pady=6, sticky="w") + self.quarter_day_spin.grid(row=2, column=1, padx=10, pady=6, sticky="w") + + # ---------- Vorschau-Text ---------- + + def _build_rule_preview(self, + year: int, + frequency_code: str, + time_str: str, + monthly_option: int | None, + day_of_month: int | None, + week_in_month: int | None, + weekday_for_monthly: int | None, + target_weekday_for_monthly: int | None, + relative_week_offset: int, + weekly_weekday: int | None, + weekly_iso_kw_parity: str | None, + monthly_iso_parity: str | None, + quarterly_option: int | None) -> str: + time_short = time_str[:5] # HH:MM + txt = f"Regel-Vorschau: " + + if frequency_code == 't': + txt += f"tĂ€glich um {time_short} im Jahr {year}" + + elif frequency_code == 'w': + if weekly_weekday is None: + return "Regel-Vorschau: wöchentlich (Wochentag nicht gesetzt)" + wname = weekday_name_de(weekly_weekday) + txt += f"wöchentlich am {wname} um {time_short} im Jahr {year}" + if weekly_iso_kw_parity == 'odd': + txt += " (nur in ungeraden Kalenderwochen)" + elif weekly_iso_kw_parity == 'even': + txt += " (nur in geraden Kalenderwochen)" + + elif frequency_code == 'm': + if monthly_option == 1: + if day_of_month is None: + return "Regel-Vorschau: monatlich – fester Kalendertag (Tag nicht gesetzt)" + txt += f"monatlich am {day_of_month}. um {time_short}" + elif monthly_option == 2: + if week_in_month is None or weekday_for_monthly is None: + return "Regel-Vorschau: monatlich – wiederkehrender Wochentag (Angaben unvollstĂ€ndig)" + wname = weekday_name_de(weekday_for_monthly) + txt += f"monatlich am {ordinal_de(week_in_month)} {wname} um {time_short}" + if monthly_iso_parity == 'odd': + txt += " (nur in ungeraden Kalenderwochen)" + elif monthly_iso_parity == 'even': + txt += " (nur in geraden Kalenderwochen)" + elif monthly_option == 3: + if day_of_month is None: + return "Regel-Vorschau: monatlich – Kalendertag mit Folgewerktag (Tag nicht gesetzt)" + txt += ( + f"monatlich am {day_of_month}. bzw. verschoben auf den nĂ€chsten Werktag (Mo–Fr) " + f"um {time_short}" + ) + elif monthly_option == 4: + txt += f"monatlich am ersten Werktag des Monats (Mo–Fr) um {time_short}" + elif monthly_option == 5: + if week_in_month is None or weekday_for_monthly is None or target_weekday_for_monthly is None: + return "Regel-Vorschau: monatlich – Termin abhĂ€ngig von anderem Datum (Angaben unvollstĂ€ndig)" + ref_name = weekday_name_de(weekday_for_monthly) + tgt_name = weekday_name_de(target_weekday_for_monthly) + txt += ( + f"monatlich am {tgt_name} " + f"in Bezug auf den {ordinal_de(week_in_month)} {ref_name} " + f"um {time_short}" + ) + if relative_week_offset == 1: + txt += " (eine Woche nach dem Referenztermin)" + elif relative_week_offset == -1: + txt += " (eine Woche vor dem Referenztermin)" + elif relative_week_offset > 1: + txt += f" ({relative_week_offset} Wochen nach dem Referenztermin)" + elif relative_week_offset < -1: + txt += f" ({abs(relative_week_offset)} Wochen vor dem Referenztermin)" + + elif frequency_code == 'q': + if quarterly_option is None or quarterly_option == 1: + txt += f"quartalsweise am letzten Kalendertag des Quartals um {time_short} im Jahr {year}" + elif quarterly_option == 2: + if day_of_month is None: + return "Regel-Vorschau: quartalsweise – fester Kalendertag im Quartalsmonat (Tag nicht gesetzt)" + txt += ( + f"quartalsweise am {day_of_month}. im Quartalsmonat " + f"(MĂ€rz, Juni, September, Dezember) um {time_short} im Jahr {year}" + ) + else: + txt += "quartalsweise (unbekannte Option)" + + else: + txt += "unbekannte Regel." + + return txt + + # ---------- Aktionen ---------- + + def on_run(self): + try: + year = int(self.year_var.get()) + frequency_code = self._get_frequency_code() + time_str = validate_time_format(self.time_var.get().strip()) + + # gemeinsame Variablen + weekday_for_weekly = None + weekly_iso_parity = None + monthly_option = None + dom = None + wim = None + weekday_for_monthly = None + target_weekday_for_monthly = None + rel_week_offset = 0 + monthly_iso_parity = None + quarterly_option = None + + # Wöchentlich + if frequency_code == 'w': + weekday_for_weekly = int(self.weekly_weekday_var.get()) + sel_w = self.weekly_iso_kw_var.get().strip().lower() + if "ungeraden" in sel_w: + weekly_iso_parity = 'odd' + elif "geraden" in sel_w: + weekly_iso_parity = 'even' + else: + weekly_iso_parity = None + + # Monatlich + if frequency_code == 'm': + monthly_option = int(self.monthly_option_var.get()) + if monthly_option == 1: + dom = int(self.day_of_month_var.get()) + elif monthly_option == 2: + wim = int(self.week_in_month_var.get()) + weekday_for_monthly = int(self.monthly_weekday_var.get()) + sel_m = self.iso_kw_filter_var.get().strip().lower() + if "ungeraden" in sel_m: + monthly_iso_parity = 'odd' + elif "geraden" in sel_m: + monthly_iso_parity = 'even' + else: + monthly_iso_parity = None + elif monthly_option == 3: + dom = int(self.day_of_month_var.get()) + elif monthly_option == 4: + # keine Zusatzfelder + pass + elif monthly_option == 5: + wim = int(self.week_in_month_var.get()) + weekday_for_monthly = int(self.monthly_weekday_var.get()) + target_weekday_for_monthly = int(self.monthly_target_weekday_var.get()) + rel_week_offset = int(self.monthly_week_offset_var.get()) + + # Quartalsweise + if frequency_code == 'q': + quarterly_option = int(self.quarterly_option_var.get()) + if quarterly_option == 2: + dom = int(self.quarter_day_var.get()) + + # Vorschau-Text bauen + preview = self._build_rule_preview( + year=year, + frequency_code=frequency_code, + time_str=time_str, + monthly_option=monthly_option, + day_of_month=dom, + week_in_month=wim, + weekday_for_monthly=weekday_for_monthly, + target_weekday_for_monthly=target_weekday_for_monthly, + relative_week_offset=rel_week_offset, + weekly_weekday=weekday_for_weekly, + weekly_iso_kw_parity=weekly_iso_parity, + monthly_iso_parity=monthly_iso_parity, + quarterly_option=quarterly_option + ) + self.preview_var.set(preview) + + # Termine generieren + dates = generate_dates( + year=year, + frequency=frequency_code, + time_hhmmss=time_str, + weekday_for_weekly=weekday_for_weekly, + weekly_iso_week_parity=weekly_iso_parity, + monthly_option=monthly_option, + day_of_month=dom, + week_in_month=wim, + weekday_for_monthly=weekday_for_monthly, + target_weekday_for_monthly=target_weekday_for_monthly, + relative_week_offset=rel_week_offset, + monthly_iso_week_parity=monthly_iso_parity, + quarterly_option=quarterly_option + ) + + self.result_list.delete(0, tk.END) + for d in dates: + self.result_list.insert(tk.END, d) + + if HAS_PYPERCLIP: + pyperclip.copy("\n".join(dates)) + messagebox.showinfo("Fertig", f"{len(dates)} EintrĂ€ge generiert und in die Zwischenablage kopiert.") + else: + messagebox.showinfo( + "Fertig", + f"{len(dates)} EintrĂ€ge generiert.\n" + f"(Hinweis: pyperclip nicht installiert – nichts kopiert.)" + ) + + except Exception as e: + messagebox.showerror("Fehler", str(e)) + + def on_clear(self): + self.result_list.delete(0, tk.END) + self.preview_var.set("Regel-Vorschau: (noch nichts berechnet)") + + +if __name__ == "__main__": + App().mainloop() diff --git a/CP Tagesrechner Jahresplanung/tagesrechner_user-manual.docx b/CP Tagesrechner Jahresplanung/tagesrechner_user-manual.docx new file mode 100644 index 0000000..518c1db Binary files /dev/null and b/CP Tagesrechner Jahresplanung/tagesrechner_user-manual.docx differ