First Upload

This commit is contained in:
2025-11-19 17:32:48 +01:00
parent b37191725c
commit de85f4e30d
3 changed files with 896 additions and 0 deletions

View File

@@ -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

View File

@@ -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 (MoFr)
# 4) 🏢 Erster Werktag des Monats (MoFr)
# 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 023, Minuten 059).")
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 (MoFr) 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 (MoFr) 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 (MoFr)
4 = 🏢 Erster Werktag des Monats (MoFr)
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 (MoFr)
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 (MoFr)
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('<<ComboboxSelected>>', 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 (MoFr)",
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 (MoFr)",
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 (MoFr) 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 (MoFr) "
f"um {time_short}"
)
elif monthly_option == 4:
txt += f"monatlich am ersten Werktag des Monats (MoFr) 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()