German date display + optional street address for precise geocoding

- Date pickers (entry form + edit dialog) now display TT.MM.JJJJ instead of
  ISO (DATE_DISPLAY_FORMAT). Storage stays YYYY-MM-DD; parse_date() already
  accepted both formats, so existing data and the self-updater are unaffected.
- New optional "Straße" field (street + house number) in the entry form and
  edit dialog, backed by a new `street` CSV column. geocode_city() and
  GeocoderWorker.enqueue() gained a street parameter: when set, a full-address
  query is tried first for a much more precise map point, falling back
  automatically to the existing city/PLZ search if it doesn't resolve.
- Tab 2 and the entry queue show a Straße column; Tab 2 search now also
  matches on street.
- Fix: pandas turns a blank CSV cell into NaN even for a dtype=str column, so
  every existing (blank-street) row would have shown literal "nan" in Tab 2.
  DataStore._load now does street.fillna("") after every read.

Verified with a non-GUI test suite (date parsing, query construction, CSV
round-trip incl. the NaN case) and a full GUI build/drive test on Python
3.12/Tk 9. Docs updated (changelog, overview, architecture, data-model,
dev-notes, improvements).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-12 11:15:37 +02:00
parent 0b6ed854dc
commit d418f156fa
7 changed files with 185 additions and 61 deletions
+73 -33
View File
@@ -43,7 +43,10 @@ LOG_PATH = DATA_DIR / "app.log"
ICON_PATH = BASE_DIR / "icon.png" # mitgeliefert (für Fenster + Startmenü)
ICON_FALLBACK_PATH = DATA_DIR / "icon.png" # zur Laufzeit gezeichnet, falls obiges fehlt
WINDOW_STATE_PATH = DATA_DIR / "window.json"
CSV_COLUMNS = ["date", "city", "postal_code", "lat", "lon"]
CSV_COLUMNS = ["date", "city", "postal_code", "street", "lat", "lon"]
#: Anzeigeformat des Datums-Feldes (deutsch). Gespeichert wird trotzdem immer ISO.
DATE_DISPLAY_FORMAT = "%d.%m.%Y"
#: Wie viele automatische Backups von orte.csv aufbewahrt werden.
MAX_BACKUPS = 20
@@ -203,6 +206,9 @@ class DataStore:
if col not in self.df.columns:
self.df[col] = ""
self.df = self.df[CSV_COLUMNS]
# Leere Zellen liest pandas als NaN ein (auch bei dtype=str) "street"
# ist optional und daher oft leer, das würde sonst als "nan" angezeigt.
self.df["street"] = self.df["street"].fillna("")
self._refresh_known()
def _load_from_newest_backup(self) -> pd.DataFrame:
@@ -321,14 +327,24 @@ class GeocodingUnavailable(Exception):
"""Alle Geokodier-Anfragen sind an einem Dienst-/Netzwerkfehler gescheitert."""
def geocode_city(city: str, postal_code: str) -> tuple[float, float] | None:
"""Sucht Koordinaten für ``city``/``postal_code``.
def geocode_city(city: str, postal_code: str, street: str = "") -> tuple[float, float] | None:
"""Sucht Koordinaten für ``city``/``postal_code``, optional mit ``street``.
Ist eine Straße (inkl. Hausnummer) angegeben, wird zuerst die genaue Adresse
versucht das liefert einen deutlich präziseren Punkt als nur Ort/PLZ.
Schlägt das fehl (z. B. Tippfehler, unbekannte Hausnummer), fällt die Suche
automatisch auf Ort/PLZ zurück, genau wie ohne Straßenangabe.
Rückgabe ``None`` bedeutet "Ort nicht gefunden". Ein
:class:`GeocodingUnavailable` wird geworfen, wenn *jede* Anfrage an einem
Dienst- oder Netzwerkfehler gescheitert ist (z. B. keine Internetverbindung).
"""
queries: list[str] = []
if street:
if postal_code:
queries.append(f"{street}, {postal_code} {city}, Germany")
else:
queries.append(f"{street}, {city}, Germany")
if postal_code:
queries.append(f"{postal_code} {city}, Germany")
queries += [f"{city}, {region}, Germany" for region in GEOCODE_REGIONS]
@@ -365,16 +381,16 @@ class GeocoderWorker(threading.Thread):
def __init__(self, on_service_error=None) -> None:
super().__init__(daemon=True)
self._items: list[tuple[str, str, str, object]] = []
self._items: list[tuple[str, str, str, str, object]] = []
self._lock = threading.Lock()
self._event = threading.Event()
self._stopped = False
self._on_service_error = on_service_error
self._consecutive_errors = 0
def enqueue(self, row_id: str, city: str, plz: str, callback) -> None:
def enqueue(self, row_id: str, city: str, plz: str, street: str, callback) -> None:
with self._lock:
self._items.append((row_id, city, plz, callback))
self._items.append((row_id, city, plz, street, callback))
self._event.set()
def run(self) -> None:
@@ -385,11 +401,11 @@ class GeocoderWorker(threading.Thread):
with self._lock:
if not self._items:
break
row_id, city, plz, callback = self._items.pop(0)
row_id, city, plz, street, callback = self._items.pop(0)
coords: tuple[float, float] | None = None
try:
coords = geocode_city(city, plz)
coords = geocode_city(city, plz, street)
self._consecutive_errors = 0
except GeocodingUnavailable:
self._consecutive_errors += 1
@@ -577,12 +593,9 @@ class EditDialog(tk.Toplevel):
start = parse_date(str(row.get("date", "")))
start_dt = datetime.strptime(start, "%Y-%m-%d") if start else None
self._date_entry = ttk.DateEntry(
self, dateformat="%Y-%m-%d", width=12, startdate=start_dt
self, dateformat=DATE_DISPLAY_FORMAT, width=12, startdate=start_dt
)
self._date_entry.grid(row=0, column=1, sticky=W, **pad)
if start:
self._date_entry.entry.delete(0, END)
self._date_entry.entry.insert(0, start)
ttk.Label(self, text="Ort:").grid(row=1, column=0, sticky=W, **pad)
self._city_var = tk.StringVar(value=str(row.get("city", "")))
@@ -596,14 +609,20 @@ class EditDialog(tk.Toplevel):
self._plz_var = tk.StringVar(value=str(row.get("postal_code", "")))
ttk.Entry(self, textvariable=self._plz_var, width=10).grid(row=2, column=1, sticky=W, **pad)
ttk.Label(self, text="Straße:").grid(row=3, column=0, sticky=W, **pad)
self._street_var = tk.StringVar(value=str(row.get("street", "")))
ttk.Entry(self, textvariable=self._street_var, width=28).grid(
row=3, column=1, sticky=W, **pad
)
self._regeocode_var = tk.BooleanVar(value=False)
ttk.Checkbutton(
self, text="Koordinaten automatisch neu suchen", variable=self._regeocode_var,
bootstyle="round-toggle", command=self._on_regeocode_toggle,
).grid(row=3, column=0, columnspan=2, sticky=W, **pad)
).grid(row=4, column=0, columnspan=2, sticky=W, **pad)
coord_frame = ttk.LabelFrame(self, text=" Koordinaten (manuell) ", padding=(8, 4))
coord_frame.grid(row=4, column=0, columnspan=2, sticky=EW, padx=8, pady=(4, 4))
coord_frame.grid(row=5, column=0, columnspan=2, sticky=EW, padx=8, pady=(4, 4))
ttk.Label(coord_frame, text="Breitengrad:").grid(row=0, column=0, sticky=W, padx=(0, 4), pady=2)
self._lat_var = tk.StringVar(value=_coord_str(row.get("lat", "")))
self._lat_entry = ttk.Entry(coord_frame, textvariable=self._lat_var, width=14)
@@ -617,7 +636,7 @@ class EditDialog(tk.Toplevel):
).grid(row=2, column=0, columnspan=2, sticky=W, pady=(2, 0))
btn_frame = ttk.Frame(self)
btn_frame.grid(row=5, column=0, columnspan=2, pady=(8, 6))
btn_frame.grid(row=6, column=0, columnspan=2, pady=(8, 6))
ttk.Button(btn_frame, text="Speichern", bootstyle="success",
command=self._on_save).pack(side=LEFT, padx=6)
ttk.Button(btn_frame, text="Abbrechen", bootstyle="secondary-outline",
@@ -654,7 +673,7 @@ class EditDialog(tk.Toplevel):
if iso is None:
messagebox.showwarning(
"Ungültiges Datum",
"Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben.",
"Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben.",
parent=self,
)
return
@@ -686,6 +705,7 @@ class EditDialog(tk.Toplevel):
"date": iso,
"city": city,
"postal_code": self._plz_var.get().strip(),
"street": self._street_var.get().strip(),
"regeocode": regeocode,
"lat": lat,
"lon": lon,
@@ -1004,7 +1024,7 @@ class App(ttk.Window):
plz_ok = (self.register(lambda P: P == "" or (P.isdigit() and len(P) <= 5)), "%P")
ttk.Label(input_frame, text="Datum:").grid(row=0, column=0, sticky=W, padx=(0, 4), pady=4)
self._date_entry = ttk.DateEntry(input_frame, dateformat="%Y-%m-%d", width=12)
self._date_entry = ttk.DateEntry(input_frame, dateformat=DATE_DISPLAY_FORMAT, width=12)
self._date_entry.grid(row=0, column=1, sticky=W, padx=(0, 16), pady=4)
ttk.Label(input_frame, text="Ort:").grid(row=0, column=2, sticky=W, padx=(0, 4), pady=4)
@@ -1029,25 +1049,36 @@ class App(ttk.Window):
)
add_btn.grid(row=0, column=6, pady=4)
for w in (self._date_entry.entry, self.city_cb, plz_entry):
ttk.Label(input_frame, text="Straße:").grid(row=1, column=0, sticky=W, padx=(0, 4), pady=4)
self._street_var = ttk.StringVar()
street_entry = ttk.Entry(input_frame, textvariable=self._street_var, width=40)
street_entry.grid(row=1, column=1, columnspan=4, sticky=EW, pady=4)
ttk.Label(
input_frame, text="optional, für genauere Kartenpunkte", bootstyle="secondary",
).grid(row=1, column=5, columnspan=2, sticky=W, padx=(8, 0), pady=4)
for w in (self._date_entry.entry, self.city_cb, plz_entry, street_entry):
w.bind("<Return>", lambda _e: self._on_add_row())
# Warteschlange
queue_frame = ttk.LabelFrame(parent, text=" Warteschlange (noch nicht gespeichert) ", padding=(8, 4))
queue_frame.pack(fill=BOTH, expand=True, padx=4, pady=4)
cols = ("date", "city", "postal_code", "coords", "del")
cols = ("date", "city", "postal_code", "street", "coords", "del")
self.tree = ttk.Treeview(queue_frame, columns=cols, show="headings", height=9)
self.tree.heading("date", text="Datum")
self.tree.heading("city", text="Ort")
self.tree.heading("postal_code", text="PLZ")
self.tree.heading("street", text="Straße")
self.tree.heading("coords", text="Koordinaten")
self.tree.heading("del", text="")
self.tree.column("date", width=100, minwidth=90, stretch=False)
self.tree.column("city", width=180, minwidth=120)
self.tree.column("city", width=150, minwidth=110)
self.tree.column("postal_code", width=65, minwidth=50, stretch=False)
self.tree.column("street", width=150, minwidth=100)
self.tree.column("coords", width=185, minwidth=130)
self.tree.column("del", width=32, minwidth=32, stretch=False, anchor=CENTER)
self._queue_del_col = f"#{cols.index('del') + 1}"
sb = ttk.Scrollbar(queue_frame, orient=VERTICAL, command=self.tree.yview)
self.tree.configure(yscrollcommand=sb.set)
@@ -1104,7 +1135,8 @@ class App(ttk.Window):
hist_frame.pack(fill=BOTH, expand=True)
self._hist_headings = {
"date": "Datum", "city": "Ort", "postal_code": "PLZ", "coords": "Karte",
"date": "Datum", "city": "Ort", "postal_code": "PLZ",
"street": "Straße", "coords": "Karte",
}
cols = tuple(self._hist_headings)
self.hist_tree = ttk.Treeview(hist_frame, columns=cols, show="headings", height=16)
@@ -1114,8 +1146,9 @@ class App(ttk.Window):
command=lambda k=key: self._sort_history(k),
)
self.hist_tree.column("date", width=115, minwidth=95, stretch=False)
self.hist_tree.column("city", width=260, minwidth=140)
self.hist_tree.column("city", width=190, minwidth=120)
self.hist_tree.column("postal_code", width=80, minwidth=55, stretch=False, anchor=CENTER)
self.hist_tree.column("street", width=180, minwidth=110)
self.hist_tree.column("coords", width=90, minwidth=70, stretch=False, anchor=CENTER)
sb2 = ttk.Scrollbar(hist_frame, orient=VERTICAL, command=self.hist_tree.yview)
@@ -1170,7 +1203,8 @@ class App(ttk.Window):
mask = (
df["date"].astype(str).str.lower().str.contains(query) |
df["city"].astype(str).str.lower().str.contains(query) |
df["postal_code"].astype(str).str.lower().str.contains(query)
df["postal_code"].astype(str).str.lower().str.contains(query) |
df["street"].astype(str).str.lower().str.contains(query)
)
df = df[mask]
if self._only_missing_var.get():
@@ -1193,6 +1227,7 @@ class App(ttk.Window):
row.get("date", ""),
row.get("city", ""),
row.get("postal_code", ""),
row.get("street", ""),
"fehlt" if row_missing else "",
),
)
@@ -1244,11 +1279,12 @@ class App(ttk.Window):
row = self.store.df.loc[df_idx]
city = str(row.get("city", "")).strip()
plz = str(row.get("postal_code", "")).strip()
street = str(row.get("street", "")).strip()
if not city:
self._set_status("Eintrag hat keinen Ort Koordinatensuche nicht möglich.")
return
self.geocoder.enqueue(
f"edit_{df_idx}", city, plz,
f"edit_{df_idx}", city, plz, street,
lambda _rid, coords, idx=df_idx: self.after(0, self._apply_edit_geocode, idx, coords),
)
self._set_status(f"Koordinaten werden gesucht für {city}")
@@ -1271,6 +1307,7 @@ class App(ttk.Window):
"date": dlg.result["date"],
"city": dlg.result["city"],
"postal_code": dlg.result["postal_code"],
"street": dlg.result["street"],
}
if dlg.result["regeocode"]:
@@ -1282,6 +1319,7 @@ class App(ttk.Window):
f"edit_{df_idx}",
dlg.result["city"],
dlg.result["postal_code"],
dlg.result["street"],
lambda _rid, coords, idx=df_idx: self.after(0, self._apply_edit_geocode, idx, coords),
)
self._set_status(f"Koordinaten werden gesucht für {new_vals['city']}")
@@ -1354,16 +1392,17 @@ class App(ttk.Window):
def _on_add_row(self) -> None:
city = self._city_var.get().strip()
plz = self._plz_var.get().strip()
street = self._street_var.get().strip()
date_str = parse_date(self._date_entry.entry.get())
if not city:
self._set_status("Bitte einen Ort eingeben.", "warning")
return
if date_str is None:
self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.", "warning")
self._set_status("Bitte ein gültiges Datum (TT.MM.JJJJ) eingeben.", "warning")
messagebox.showwarning(
"Ungültiges Datum",
"Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben "
"Bitte ein gültiges Datum im Format TT.MM.JJJJ eingeben "
"oder den Kalender-Knopf benutzen.",
parent=self,
)
@@ -1372,15 +1411,16 @@ class App(ttk.Window):
row_id = str(self._next_id)
self._next_id += 1
row = {"_id": row_id, "date": date_str, "city": city,
"postal_code": plz, "lat": "", "lon": ""}
"postal_code": plz, "street": street, "lat": "", "lon": ""}
self._queue.append(row)
self.tree.insert("", END, iid=row_id,
values=(date_str, city, plz, "⏳ wird gesucht…", ""))
self.geocoder.enqueue(row_id, city, plz, self._geocode_done)
values=(date_str, city, plz, street, "⏳ wird gesucht…", ""))
self.geocoder.enqueue(row_id, city, plz, street, self._geocode_done)
self._city_var.set("")
self._plz_var.set("")
self._street_var.set("")
self.city_cb.focus_set()
self._set_status(
f"{len(self._queue)} Einträge in der Warteschlange.", sticky=True
@@ -1404,13 +1444,13 @@ class App(ttk.Window):
if self.tree.exists(row_id):
vals = list(self.tree.item(row_id, "values"))
vals[3] = coord_str
vals[4] = coord_str
self.tree.item(row_id, values=vals)
def _on_tree_click(self, event) -> None:
if self.tree.identify_region(event.x, event.y) != "cell":
return
if self.tree.identify_column(event.x) == "#5":
if self.tree.identify_column(event.x) == self._queue_del_col:
row_id = self.tree.identify_row(event.y)
if row_id:
self._delete_queue_row(row_id)
@@ -1448,10 +1488,10 @@ class App(ttk.Window):
if row["_id"] == row_id:
if self.tree.exists(row_id):
vals = list(self.tree.item(row_id, "values"))
vals[3] = "⏳ wird gesucht…"
vals[4] = "⏳ wird gesucht…"
self.tree.item(row_id, values=vals)
self.geocoder.enqueue(row_id, row["city"], row["postal_code"],
self._geocode_done)
row.get("street", ""), self._geocode_done)
break
def _on_clear_queue(self) -> None: