UI/UX pass: readability, feedback, safer actions, DRK branding

Items 1–12 of the interface review:

- Larger UI font (UI_FONT_SIZE) across widgets, menus, dialogs, Treeview rows.
- DRK-red header bar; window title shows entry count; runtime-drawn red-cross
  icon (also written to data/icon.png for the launcher).
- Status bar colour-coded by severity; transient messages auto-clear after 8 s,
  queue-count messages stay.
- Tab 1: more form spacing, digits-only PLZ, explicit "Auswahl entfernen" +
  Delete key for queue rows, confirm on "Warteschlange leeren", modal on
  duplicate save.
- Tab 2: raw lat/lon columns replaced by one "Karte" column (checkmark / red
  "fehlt"); ▲/▼ arrow on the active sort column; hint line always shows the
  saved-entry count.
- "Karte öffnen" with no coordinates shows a dialog, not just a status line.
- Window size/position remembered between sessions (data/window.json).

Verified by building and driving the window under Homebrew Python 3.12 / Tk 9;
non-GUI smoke tests still pass. Docs updated (changelog, overview, improvements,
data-model, setup, dev-notes).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 18:51:17 +02:00
parent 37c9642877
commit caecc60c7b
9 changed files with 349 additions and 103 deletions
+3 -1
View File
@@ -11,10 +11,12 @@ data/karte.html
# Real data — kept out of version control for now (see docs/data-model.md) # Real data — kept out of version control for now (see docs/data-model.md)
data/orte.csv data/orte.csv
# Local backups and logs # Local backups, logs and generated/per-machine files
data/backups/ data/backups/
data/app.log data/app.log
data/app.log.* data/app.log.*
data/window.json
data/icon.png
# Editor / OS # Editor / OS
.vscode/ .vscode/
+234 -70
View File
@@ -6,6 +6,7 @@ Visualisiert Einsatzorte auf einer interaktiven Karte.
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import math import math
import os import os
@@ -26,6 +27,7 @@ from geopy.geocoders import Nominatim
from ttkbootstrap.constants import * from ttkbootstrap.constants import *
import tkinter as tk import tkinter as tk
import tkinter.font as tkfont
from tkinter import messagebox from tkinter import messagebox
# ── Pfade ──────────────────────────────────────────────────────────────────── # ── Pfade ────────────────────────────────────────────────────────────────────
@@ -36,11 +38,19 @@ CSV_PATH = DATA_DIR / "orte.csv"
MAP_PATH = DATA_DIR / "karte.html" MAP_PATH = DATA_DIR / "karte.html"
BACKUP_DIR = DATA_DIR / "backups" BACKUP_DIR = DATA_DIR / "backups"
LOG_PATH = DATA_DIR / "app.log" LOG_PATH = DATA_DIR / "app.log"
ICON_PATH = DATA_DIR / "icon.png"
WINDOW_STATE_PATH = DATA_DIR / "window.json"
CSV_COLUMNS = ["date", "city", "postal_code", "lat", "lon"] CSV_COLUMNS = ["date", "city", "postal_code", "lat", "lon"]
#: Wie viele automatische Backups von orte.csv aufbewahrt werden. #: Wie viele automatische Backups von orte.csv aufbewahrt werden.
MAX_BACKUPS = 20 MAX_BACKUPS = 20
#: Schriftgröße der Oberfläche (größer = besser lesbar auf einem Laptop).
UI_FONT_SIZE = 11
#: DRK-Rot für Akzente in der Oberfläche und die Kartenpunkte.
DRK_RED = "#CC0000"
#: Regionen, die bei der Geokodierung bevorzugt durchsucht werden (Reihenfolge zählt). #: Regionen, die bei der Geokodierung bevorzugt durchsucht werden (Reihenfolge zählt).
GEOCODE_REGIONS = ["Baden-Württemberg", "Hessen"] GEOCODE_REGIONS = ["Baden-Württemberg", "Hessen"]
@@ -419,9 +429,9 @@ def generate_map(store: DataStore) -> Path:
f"<b>{row['city']}</b><br>{count} Einsatz/Einsätze", max_width=200 f"<b>{row['city']}</b><br>{count} Einsatz/Einsätze", max_width=200
), ),
tooltip=str(row["city"]), tooltip=str(row["city"]),
color="#CC0000", color=DRK_RED,
fill=True, fill=True,
fill_color="#CC0000", fill_color=DRK_RED,
fill_opacity=0.65, fill_opacity=0.65,
weight=2, weight=2,
).add_to(m) ).add_to(m)
@@ -579,10 +589,17 @@ class EditDialog(tk.Toplevel):
class App(ttk.Window): class App(ttk.Window):
#: Statusmeldungs-Art → ttkbootstrap-Style der Statusleiste.
_STATUS_STYLES = {
"info": "secondary", "success": "success",
"warning": "warning", "error": "danger",
}
def __init__(self) -> None: def __init__(self) -> None:
super().__init__(themename="litera") super().__init__(themename="litera")
self.title("DRK Blutspende Arbeitsorte") self.minsize(820, 620)
self.minsize(760, 560) self._scale_fonts()
self._install_icon()
self.store = DataStore() self.store = DataStore()
self.geocoder = GeocoderWorker( self.geocoder = GeocoderWorker(
@@ -593,17 +610,82 @@ class App(ttk.Window):
self._queue: list[dict] = [] self._queue: list[dict] = []
self._next_id = 0 self._next_id = 0
self._status_after_id: str | None = None
self._build_ui() self._build_ui()
self._set_status(f"Bereit. Gesamt: {self.store.total_count()} Einträge.") self._update_title()
self._restore_window_state()
self._reset_status()
self.protocol("WM_DELETE_WINDOW", self._on_close) self.protocol("WM_DELETE_WINDOW", self._on_close)
log.info("App gestartet (%d Einträge).", self.store.total_count()) log.info("App gestartet (%d Einträge).", self.store.total_count())
# ── Aussehen ──────────────────────────────────────────────────────────────
def _scale_fonts(self) -> None:
"""Vergrößert die Standard-Schriftarten deutlich besser lesbar."""
for name in ("TkDefaultFont", "TkTextFont", "TkMenuFont", "TkHeadingFont"):
try:
tkfont.nametofont(name).configure(size=UI_FONT_SIZE)
except tk.TclError:
pass
self._ui_family = tkfont.nametofont("TkDefaultFont").actual("family")
self.style.configure(".", font=(self._ui_family, UI_FONT_SIZE))
self.style.configure(
"Treeview", font=(self._ui_family, UI_FONT_SIZE),
rowheight=UI_FONT_SIZE * 2 + 8,
)
self.style.configure(
"Treeview.Heading", font=(self._ui_family, UI_FONT_SIZE, "bold")
)
def _install_icon(self) -> None:
"""Zeichnet ein einfaches rotes Kreuz als Fenster-Icon (ohne Extra-Abhängigkeit)."""
try:
size = 64
img = tk.PhotoImage(width=size, height=size)
img.put("white", to=(0, 0, size, size))
arm = size // 3
img.put(DRK_RED, to=(arm, 7, size - arm, size - 7))
img.put(DRK_RED, to=(7, arm, size - 7, size - arm))
self.iconphoto(True, img)
self._icon_img = img # Referenz halten
if not ICON_PATH.exists():
try:
img.write(str(ICON_PATH), format="png")
except tk.TclError:
pass # Tk < 8.6 kann kein PNG schreiben nicht kritisch
except tk.TclError:
log.warning("Fenster-Icon konnte nicht gesetzt werden.")
def _update_title(self) -> None:
n = self.store.total_count()
self.title(f"DRK Blutspende Arbeitsorte · {n} Einsätze")
# ── Fensterposition merken ────────────────────────────────────────────────
def _restore_window_state(self) -> None:
try:
geom = json.loads(WINDOW_STATE_PATH.read_text()).get("geometry")
except (OSError, ValueError):
return
if isinstance(geom, str) and re.match(r"^\d+x\d+\+-?\d+\+-?\d+$", geom):
try:
self.geometry(geom)
except tk.TclError:
pass
def _save_window_state(self) -> None:
try:
WINDOW_STATE_PATH.write_text(json.dumps({"geometry": self.geometry()}))
except OSError:
log.warning("Fensterposition konnte nicht gespeichert werden.")
def _on_geocode_service_error(self) -> None: def _on_geocode_service_error(self) -> None:
"""Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist.""" """Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist."""
self._set_status( self._set_status(
"Standortdienst nicht erreichbar Koordinaten werden übersprungen. " "Standortdienst nicht erreichbar Koordinaten werden übersprungen. "
"Bitte Internetverbindung prüfen." "Bitte Internetverbindung prüfen.",
"error",
) )
if not self._service_error_shown: if not self._service_error_shown:
self._service_error_shown = True self._service_error_shown = True
@@ -618,12 +700,22 @@ class App(ttk.Window):
# ── UI-Aufbau ───────────────────────────────────────────────────────────── # ── UI-Aufbau ─────────────────────────────────────────────────────────────
def _build_ui(self) -> None: def _build_ui(self) -> None:
# Kopfzeile mit DRK-Rot
header = ttk.Frame(self, bootstyle="danger", padding=(14, 8))
header.pack(fill=X, side=TOP)
ttk.Label(
header, text="DRK Blutspende Arbeitsorte",
bootstyle="inverse-danger",
font=(self._ui_family, UI_FONT_SIZE + 4, "bold"),
).pack(side=LEFT)
# Statusleiste (ganz unten, vor Notebook packen) # Statusleiste (ganz unten, vor Notebook packen)
self._status_var = ttk.StringVar() self._status_var = ttk.StringVar()
ttk.Label( self._status_label = ttk.Label(
self, textvariable=self._status_var, bootstyle="secondary", self, textvariable=self._status_var, bootstyle="secondary",
anchor=W, padding=(12, 4), anchor=W, padding=(12, 5),
).pack(fill=X, side=BOTTOM) )
self._status_label.pack(fill=X, side=BOTTOM)
# Notebook mit zwei Tabs # Notebook mit zwei Tabs
self.notebook = ttk.Notebook(self) self.notebook = ttk.Notebook(self)
@@ -646,31 +738,33 @@ class App(ttk.Window):
input_frame = ttk.LabelFrame(parent, text=" Eintrag ", padding=10) input_frame = ttk.LabelFrame(parent, text=" Eintrag ", padding=10)
input_frame.pack(fill=X, padx=4, pady=(4, 6)) input_frame.pack(fill=X, padx=4, pady=(4, 6))
ttk.Label(input_frame, text="Datum:").grid(row=0, column=0, sticky=W, padx=(0, 4)) plz_ok = (self.register(lambda P: P == "" or (P.isdigit() and len(P) <= 5)), "%P")
self._date_entry = ttk.DateEntry(input_frame, dateformat="%Y-%m-%d", width=12)
self._date_entry.grid(row=0, column=1, sticky=W, padx=(0, 14))
ttk.Label(input_frame, text="(JJJJ-MM-TT)", bootstyle="secondary").grid(
row=1, column=1, sticky=W
)
ttk.Label(input_frame, text="Ort:").grid(row=0, column=2, sticky=W, padx=(0, 4)) 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.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)
self._city_var = ttk.StringVar() self._city_var = ttk.StringVar()
self.city_cb = ttk.Combobox(input_frame, textvariable=self._city_var, width=24) self.city_cb = ttk.Combobox(input_frame, textvariable=self._city_var, width=24)
self.city_cb["values"] = self.store.get_autocomplete_strings() self.city_cb["values"] = self.store.get_autocomplete_strings()
self.city_cb.grid(row=0, column=3, sticky=W, padx=(0, 14)) self.city_cb.grid(row=0, column=3, sticky=W, padx=(0, 16), pady=4)
self.city_cb.bind("<KeyRelease>", self._on_city_key) self.city_cb.bind("<KeyRelease>", self._on_city_key)
self.city_cb.bind("<<ComboboxSelected>>", self._on_city_selected) self.city_cb.bind("<<ComboboxSelected>>", self._on_city_selected)
ttk.Label(input_frame, text="PLZ:").grid(row=0, column=4, sticky=W, padx=(0, 4)) ttk.Label(input_frame, text="PLZ:").grid(row=0, column=4, sticky=W, padx=(0, 4), pady=4)
self._plz_var = ttk.StringVar() self._plz_var = ttk.StringVar()
plz_entry = ttk.Entry(input_frame, textvariable=self._plz_var, width=8) plz_entry = ttk.Entry(
plz_entry.grid(row=0, column=5, sticky=W, padx=(0, 14)) input_frame, textvariable=self._plz_var, width=8,
validate="key", validatecommand=plz_ok,
)
plz_entry.grid(row=0, column=5, sticky=W, padx=(0, 16), pady=4)
add_btn = ttk.Button( add_btn = ttk.Button(
input_frame, text="+ Hinzufügen", bootstyle="success-outline", input_frame, text="+ Hinzufügen", bootstyle="success",
command=self._on_add_row, command=self._on_add_row,
) )
add_btn.grid(row=0, column=6) add_btn.grid(row=0, column=6, pady=4)
for w in (self._date_entry.entry, self.city_cb, plz_entry): for w in (self._date_entry.entry, self.city_cb, plz_entry):
w.bind("<Return>", lambda _e: self._on_add_row()) w.bind("<Return>", lambda _e: self._on_add_row())
@@ -699,13 +793,16 @@ class App(ttk.Window):
self.tree.bind("<ButtonRelease-1>", self._on_tree_click) self.tree.bind("<ButtonRelease-1>", self._on_tree_click)
self.tree.bind("<Button-3>", self._on_queue_right_click) self.tree.bind("<Button-3>", self._on_queue_right_click)
self.tree.bind("<Delete>", lambda _e: self._delete_selected_queue_rows())
# Aktionsleiste # Aktionsleiste
action_frame = ttk.Frame(parent, padding=(4, 4)) action_frame = ttk.Frame(parent, padding=(4, 6))
action_frame.pack(fill=X) action_frame.pack(fill=X)
ttk.Button(action_frame, text="Alle speichern", bootstyle="success", ttk.Button(action_frame, text="Alle speichern", bootstyle="success",
command=self._on_save_all).pack(side=LEFT, padx=(0, 6)) command=self._on_save_all).pack(side=LEFT, padx=(0, 6))
ttk.Button(action_frame, text="Leeren", bootstyle="secondary-outline", ttk.Button(action_frame, text="Auswahl entfernen", bootstyle="secondary-outline",
command=self._delete_selected_queue_rows).pack(side=LEFT, padx=(0, 6))
ttk.Button(action_frame, text="Warteschlange leeren", bootstyle="secondary-outline",
command=self._on_clear_queue).pack(side=LEFT, padx=(0, 6)) command=self._on_clear_queue).pack(side=LEFT, padx=(0, 6))
ttk.Button(action_frame, text="Karte öffnen", bootstyle="info", ttk.Button(action_frame, text="Karte öffnen", bootstyle="info",
command=self._on_open_map).pack(side=RIGHT) command=self._on_open_map).pack(side=RIGHT)
@@ -735,36 +832,34 @@ class App(ttk.Window):
hist_frame = ttk.Frame(parent, padding=(4, 2)) hist_frame = ttk.Frame(parent, padding=(4, 2))
hist_frame.pack(fill=BOTH, expand=True) hist_frame.pack(fill=BOTH, expand=True)
cols = ("date", "city", "postal_code", "lat", "lon") self._hist_headings = {
"date": "Datum", "city": "Ort", "postal_code": "PLZ", "coords": "Karte",
}
cols = tuple(self._hist_headings)
self.hist_tree = ttk.Treeview(hist_frame, columns=cols, show="headings", height=16) self.hist_tree = ttk.Treeview(hist_frame, columns=cols, show="headings", height=16)
self.hist_tree.heading("date", text="Datum", for key in cols:
command=lambda: self._sort_history("date")) self.hist_tree.heading(
self.hist_tree.heading("city", text="Ort", key, text=self._hist_headings[key],
command=lambda: self._sort_history("city")) command=lambda k=key: self._sort_history(k),
self.hist_tree.heading("postal_code", text="PLZ", )
command=lambda: self._sort_history("postal_code")) self.hist_tree.column("date", width=115, minwidth=95, stretch=False)
self.hist_tree.heading("lat", text="Breitengrad", self.hist_tree.column("city", width=260, minwidth=140)
command=lambda: self._sort_history("lat")) self.hist_tree.column("postal_code", width=80, minwidth=55, stretch=False, anchor=CENTER)
self.hist_tree.heading("lon", text="Längengrad", self.hist_tree.column("coords", width=90, minwidth=70, stretch=False, anchor=CENTER)
command=lambda: self._sort_history("lon"))
self.hist_tree.column("date", width=105, minwidth=90, stretch=False)
self.hist_tree.column("city", width=200, minwidth=120)
self.hist_tree.column("postal_code", width=65, minwidth=50, stretch=False)
self.hist_tree.column("lat", width=110, minwidth=80, stretch=False)
self.hist_tree.column("lon", width=110, minwidth=80, stretch=False)
sb2 = ttk.Scrollbar(hist_frame, orient=VERTICAL, command=self.hist_tree.yview) sb2 = ttk.Scrollbar(hist_frame, orient=VERTICAL, command=self.hist_tree.yview)
self.hist_tree.configure(yscrollcommand=sb2.set) self.hist_tree.configure(yscrollcommand=sb2.set)
self.hist_tree.pack(side=LEFT, fill=BOTH, expand=True) self.hist_tree.pack(side=LEFT, fill=BOTH, expand=True)
sb2.pack(side=RIGHT, fill=Y) sb2.pack(side=RIGHT, fill=Y)
self.hist_tree.tag_configure("missing", foreground="#CC0000") self.hist_tree.tag_configure("missing", foreground=DRK_RED)
self.hist_tree.bind("<Double-1>", self._on_hist_double_click) self.hist_tree.bind("<Double-1>", self._on_hist_double_click)
self.hist_tree.bind("<Button-3>", self._on_hist_right_click) self.hist_tree.bind("<Button-3>", self._on_hist_right_click)
self._sort_col = "date" self._sort_col = "date"
self._sort_asc = False # neueste zuerst self._sort_asc = False # neueste zuerst
self._apply_sort_headings()
# Aktionsleiste # Aktionsleiste
action_frame = ttk.Frame(parent, padding=(4, 4)) action_frame = ttk.Frame(parent, padding=(4, 4))
@@ -773,7 +868,7 @@ class App(ttk.Window):
command=self._on_hist_edit).pack(side=LEFT, padx=(0, 6)) command=self._on_hist_edit).pack(side=LEFT, padx=(0, 6))
ttk.Button(action_frame, text="Löschen", bootstyle="danger-outline", ttk.Button(action_frame, text="Löschen", bootstyle="danger-outline",
command=self._on_hist_delete).pack(side=LEFT) command=self._on_hist_delete).pack(side=LEFT)
self._hist_hint_var = ttk.StringVar(value="(Doppelklick zum Bearbeiten)") self._hist_hint_var = ttk.StringVar()
ttk.Label(action_frame, textvariable=self._hist_hint_var, ttk.Label(action_frame, textvariable=self._hist_hint_var,
bootstyle="secondary").pack(side=RIGHT) bootstyle="secondary").pack(side=RIGHT)
@@ -788,6 +883,7 @@ class App(ttk.Window):
df = self.store.df.copy() df = self.store.df.copy()
missing = df["lat"].map(_is_blank) | df["lon"].map(_is_blank) missing = df["lat"].map(_is_blank) | df["lon"].map(_is_blank)
total = len(df)
total_missing = int(missing.sum()) total_missing = int(missing.sum())
query = self._search_var.get().strip().lower() query = self._search_var.get().strip().lower()
@@ -801,7 +897,13 @@ class App(ttk.Window):
if self._only_missing_var.get(): if self._only_missing_var.get():
df = df[missing.reindex(df.index, fill_value=False)] df = df[missing.reindex(df.index, fill_value=False)]
df = df.sort_values(self._sort_col, ascending=self._sort_asc, na_position="last") if self._sort_col == "coords":
order = missing.reindex(df.index, fill_value=True)
df = df.assign(_ord=order).sort_values("_ord", ascending=self._sort_asc)
else:
df = df.sort_values(
self._sort_col, ascending=self._sort_asc, na_position="last"
)
for idx, row in df.iterrows(): for idx, row in df.iterrows():
row_missing = _is_blank(row.get("lat", "")) or _is_blank(row.get("lon", "")) row_missing = _is_blank(row.get("lat", "")) or _is_blank(row.get("lon", ""))
@@ -812,18 +914,26 @@ class App(ttk.Window):
row.get("date", ""), row.get("date", ""),
row.get("city", ""), row.get("city", ""),
row.get("postal_code", ""), row.get("postal_code", ""),
row.get("lat", ""), "fehlt" if row_missing else "",
row.get("lon", ""),
), ),
) )
parts = [f"{total} Einsätze gespeichert"]
if total_missing: if total_missing:
self._hist_hint_var.set( parts.append(
f"{total_missing} Eintrag/Einträge ohne Koordinaten (rot) " f"{total_missing} ohne Koordinaten (rot) Rechtsklick Koordinaten suchen"
f"Rechtsklick Koordinaten suchen"
) )
else: else:
self._hist_hint_var.set("(Doppelklick zum Bearbeiten)") parts.append("Doppelklick zum Bearbeiten")
self._hist_hint_var.set(" · ".join(parts))
def _apply_sort_headings(self) -> None:
"""Zeigt einen Pfeil an der aktuell sortierten Spalte."""
arrow = "" if self._sort_asc else ""
for key, label in self._hist_headings.items():
self.hist_tree.heading(
key, text=label + (arrow if key == self._sort_col else "")
)
def _sort_history(self, col: str) -> None: def _sort_history(self, col: str) -> None:
if self._sort_col == col: if self._sort_col == col:
@@ -831,6 +941,7 @@ class App(ttk.Window):
else: else:
self._sort_col = col self._sort_col = col
self._sort_asc = True self._sort_asc = True
self._apply_sort_headings()
self._refresh_history() self._refresh_history()
def _on_hist_double_click(self, event) -> None: def _on_hist_double_click(self, event) -> None:
@@ -907,8 +1018,8 @@ class App(ttk.Window):
new_vals["lon"] = dlg.result["lon"] new_vals["lon"] = dlg.result["lon"]
self.store.update_row(df_idx, new_vals) self.store.update_row(df_idx, new_vals)
self._set_status( self._set_status(
f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}. " f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}.",
f"Gesamt: {self.store.total_count()} Einträge." "success",
) )
self.city_cb["values"] = self.store.get_autocomplete_strings() self.city_cb["values"] = self.store.get_autocomplete_strings()
@@ -923,11 +1034,12 @@ class App(ttk.Window):
"lat": round(coords[0], 5), "lat": round(coords[0], 5),
"lon": round(coords[1], 5), "lon": round(coords[1], 5),
}) })
self._set_status( self._set_status("Koordinaten aktualisiert.", "success")
f"Koordinaten aktualisiert. Gesamt: {self.store.total_count()} Einträge."
)
else: else:
self._set_status("Koordinaten konnten nicht gefunden werden.") self._set_status(
"Koordinaten konnten nicht gefunden werden ggf. manuell eintragen.",
"warning",
)
self._refresh_history() self._refresh_history()
def _on_hist_delete(self) -> None: def _on_hist_delete(self) -> None:
@@ -945,9 +1057,11 @@ class App(ttk.Window):
return return
indices = [int(iid) for iid in sel] indices = [int(iid) for iid in sel]
self.store.delete_rows(indices) self.store.delete_rows(indices)
self._update_title()
self._refresh_history() self._refresh_history()
self._set_status( self._set_status(
f"{count} Eintrag/Einträge gelöscht. Gesamt: {self.store.total_count()} Einträge." f"{count} Eintrag/Einträge gelöscht · {self.store.total_count()} insgesamt.",
"warning",
) )
# ── Autovervollständigung ───────────────────────────────────────────────── # ── Autovervollständigung ─────────────────────────────────────────────────
@@ -971,10 +1085,10 @@ class App(ttk.Window):
date_str = parse_date(self._date_entry.entry.get()) date_str = parse_date(self._date_entry.entry.get())
if not city: if not city:
self._set_status("Bitte einen Ort eingeben.") self._set_status("Bitte einen Ort eingeben.", "warning")
return return
if date_str is None: if date_str is None:
self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.") self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.", "warning")
messagebox.showwarning( messagebox.showwarning(
"Ungültiges Datum", "Ungültiges Datum",
"Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben " "Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben "
@@ -996,7 +1110,9 @@ class App(ttk.Window):
self._city_var.set("") self._city_var.set("")
self._plz_var.set("") self._plz_var.set("")
self.city_cb.focus_set() self.city_cb.focus_set()
self._set_status(f"{len(self._queue)} Einträge in der Warteschlange.") self._set_status(
f"{len(self._queue)} Einträge in der Warteschlange.", sticky=True
)
def _geocode_done(self, row_id: str, coords: tuple | None) -> None: def _geocode_done(self, row_id: str, coords: tuple | None) -> None:
self.after(0, self._apply_geocode_result, row_id, coords) self.after(0, self._apply_geocode_result, row_id, coords)
@@ -1044,7 +1160,17 @@ class App(ttk.Window):
self._queue = [r for r in self._queue if r["_id"] != row_id] self._queue = [r for r in self._queue if r["_id"] != row_id]
if self.tree.exists(row_id): if self.tree.exists(row_id):
self.tree.delete(row_id) self.tree.delete(row_id)
self._set_status(f"{len(self._queue)} Einträge in der Warteschlange.") self._set_status(
f"{len(self._queue)} Einträge in der Warteschlange.", sticky=True
)
def _delete_selected_queue_rows(self) -> None:
sel = self.tree.selection()
if not sel:
self._set_status("Bitte zuerst eine Zeile in der Warteschlange auswählen.")
return
for row_id in sel:
self._delete_queue_row(row_id)
def _retry_geocode(self, row_id: str) -> None: def _retry_geocode(self, row_id: str) -> None:
for row in self._queue: for row in self._queue:
@@ -1058,6 +1184,12 @@ class App(ttk.Window):
break break
def _on_clear_queue(self) -> None: def _on_clear_queue(self) -> None:
if self._queue and not messagebox.askyesno(
"Warteschlange leeren",
f"{len(self._queue)} noch nicht gespeicherte Einträge verwerfen?",
parent=self,
):
return
self._queue.clear() self._queue.clear()
for item in self.tree.get_children(): for item in self.tree.get_children():
self.tree.delete(item) self.tree.delete(item)
@@ -1072,11 +1204,16 @@ class App(ttk.Window):
dupes = self.store.find_duplicates(self._queue) dupes = self.store.find_duplicates(self._queue)
if dupes: if dupes:
names = ", ".join(f"{d['date']} {d['city']}" for d in dupes[:3]) listed = "\n".join(f"{d['date']} {d['city']}" for d in dupes[:8])
suffix = " u.w." if len(dupes) > 3 else "" more = f"\n … und {len(dupes) - 8} weitere" if len(dupes) > 8 else ""
self._set_status( if not messagebox.askyesno(
f"Hinweis: Mögliche Duplikate ({names}{suffix}). Trotzdem gespeichert." "Mögliche Duplikate",
) f"{len(dupes)} Eintrag/Einträge scheint es schon zu geben:\n\n"
f"{listed}{more}\n\nTrotzdem alle speichern?",
parent=self,
):
self._set_status("Speichern abgebrochen Warteschlange unverändert.")
return
self.store.append_rows(self._queue) self.store.append_rows(self._queue)
count = len(self._queue) count = len(self._queue)
@@ -1085,9 +1222,11 @@ class App(ttk.Window):
self.tree.delete(item) self.tree.delete(item)
self.city_cb["values"] = self.store.get_autocomplete_strings() self.city_cb["values"] = self.store.get_autocomplete_strings()
self._update_title()
self._refresh_history() # Tab 2 sofort aktualisieren self._refresh_history() # Tab 2 sofort aktualisieren
self._set_status( self._set_status(
f"{count} Einträge gespeichert. Gesamt: {self.store.total_count()} Einträge." f"{count} Eintrag/Einträge gespeichert · {self.store.total_count()} insgesamt.",
"success",
) )
# ── Karte ───────────────────────────────────────────────────────────────── # ── Karte ─────────────────────────────────────────────────────────────────
@@ -1096,25 +1235,50 @@ class App(ttk.Window):
try: try:
path = generate_map(self.store) path = generate_map(self.store)
except ValueError as e: except ValueError as e:
self._set_status(str(e)) messagebox.showinfo("Karte", str(e), parent=self)
self._set_status(str(e), "warning")
return return
except Exception as e: except Exception as e:
log.exception("Karte konnte nicht erstellt werden.") log.exception("Karte konnte nicht erstellt werden.")
self._set_status(f"Fehler beim Erstellen der Karte: {e}") messagebox.showerror(
"Karte", f"Die Karte konnte nicht erstellt werden:\n{e}", parent=self
)
self._set_status(f"Fehler beim Erstellen der Karte: {e}", "error")
return return
try: try:
webbrowser.open(path.as_uri()) webbrowser.open(path.as_uri())
except Exception: except Exception:
log.exception("Karte konnte nicht im Browser geöffnet werden.") log.exception("Karte konnte nicht im Browser geöffnet werden.")
self._set_status(f"Karte geöffnet: {path}") self._set_status("Karte im Browser geöffnet.", "success")
# ── Hilfsmethoden ───────────────────────────────────────────────────────── # ── Hilfsmethoden ─────────────────────────────────────────────────────────
def _set_status(self, msg: str) -> None: def _set_status(self, msg: str, kind: str = "info", *, sticky: bool = False) -> None:
"""Zeigt eine Statusmeldung, farblich nach Art, und blendet sie nach 8 s aus."""
self._status_var.set(msg) self._status_var.set(msg)
self._status_label.configure(
bootstyle=self._STATUS_STYLES.get(kind, "secondary")
)
if self._status_after_id is not None:
self.after_cancel(self._status_after_id)
self._status_after_id = None
if not sticky:
self._status_after_id = self.after(8000, self._reset_status)
def _reset_status(self) -> None:
"""Setzt die Statusleiste auf den neutralen Dauerzustand zurück."""
self._status_after_id = None
self._status_label.configure(bootstyle="secondary")
if self._queue:
self._status_var.set(
f"{len(self._queue)} Einträge in der Warteschlange noch nicht gespeichert."
)
else:
self._status_var.set("Bereit.")
def _on_close(self) -> None: def _on_close(self) -> None:
log.info("App wird beendet.") log.info("App wird beendet.")
self._save_window_state()
self.geocoder.stop() self.geocoder.stop()
self.destroy() self.destroy()
+1 -1
View File
@@ -28,7 +28,7 @@ code) and plots them on an interactive map.
- **Storage:** one CSV file, `data/orte.csv` - **Storage:** one CSV file, `data/orte.csv`
- **Map:** generated on demand as `data/karte.html` with [Folium](https://python-visualization.github.io/folium/) (Leaflet), opened in the default browser - **Map:** generated on demand as `data/karte.html` with [Folium](https://python-visualization.github.io/folium/) (Leaflet), opened in the default browser
- **Geocoding:** OpenStreetMap Nominatim via [`geopy`](https://geopy.readthedocs.io/), rate-limited to 1 request/second on a background thread - **Geocoding:** OpenStreetMap Nominatim via [`geopy`](https://geopy.readthedocs.io/), rate-limited to 1 request/second on a background thread
- **UI language:** German - **UI language:** German; DRK-red header, enlarged font, colour-coded status bar, window position remembered
- **Persistence:** atomic CSV writes + rotating backups in `data/backups/`; log at `data/app.log` - **Persistence:** atomic CSV writes + rotating backups in `data/backups/`; log at `data/app.log`
- **Tests:** non-GUI smoke checks only (not yet a committed `tests/` suite) - **Tests:** non-GUI smoke checks only (not yet a committed `tests/` suite)
- **Version control:** git initialized 2026-09-07 - **Version control:** git initialized 2026-09-07
+28 -2
View File
@@ -2,6 +2,32 @@
Notable changes to the app. Newest first. Notable changes to the app. Newest first.
## 2026-09-07 — UI/UX pass
A round of usability improvements aimed at the non-technical user
(see [improvements.md](improvements.md#uiux-review-2026-09-07)).
- **Larger UI font** (`UI_FONT_SIZE = 11`) applied to all default fonts, menus,
dialogs and the Treeview (taller rows).
- **DRK-red header bar** with the app name; window title shows the total count
("… · 142 Einsätze"); simple red-cross window icon drawn at runtime (also
written to `data/icon.png` for the launcher).
- **Status bar** is now colour-coded by severity (grey/green/orange/red), and
transient messages auto-clear after 8 s back to a neutral state; queue-count
messages stay put.
- **Tab 1:** more spacing in the form; PLZ field accepts digits only;
"+ Hinzufügen" is a solid button; explicit **"Auswahl entfernen"** button and
<kbd>Delete</kbd> key for queue rows; **"Warteschlange leeren"** now asks for
confirmation; **duplicate save shows a modal** (list + "trotzdem speichern?")
instead of a transient hint.
- **Tab 2:** the raw `lat`/`lon` columns are replaced by a single **"Karte"**
column (`✓` / red `fehlt`); active sort column shows a ▲/▼ arrow; the hint
line always shows the saved-entry count.
- **"Karte öffnen"** with no coordinates now shows a dialog, not just a status
line.
- **Window size & position** are remembered between sessions
(`data/window.json`).
## 2026-09-07 — data safety, robustness, easier input ## 2026-09-07 — data safety, robustness, easier input
Addressed the top three findings from [improvements.md](improvements.md). Addressed the top three findings from [improvements.md](improvements.md).
@@ -43,5 +69,5 @@ Addressed the top three findings from [improvements.md](improvements.md).
### Verification ### Verification
Non-GUI smoke tests (helpers, `DataStore` backup/atomic/recovery, geocoder Non-GUI smoke tests (helpers, `DataStore` backup/atomic/recovery, geocoder
resilience) pass. **The GUI could not be run on the development Mac** — see resilience) pass. The GUI now *can* be built and driven on the dev Mac via a
[dev-notes.md](dev-notes.md). Homebrew Python with Tk 9 — see [dev-notes.md](dev-notes.md).
+11
View File
@@ -59,3 +59,14 @@ closed.
Rotating log file (512 KB × 3 generations). Git-ignored. Records saves, map Rotating log file (512 KB × 3 generations). Git-ignored. Records saves, map
generation, geocoding failures, and uncaught exceptions. generation, geocoding failures, and uncaught exceptions.
## `data/window.json`
`{"geometry": "<w>x<h>+<x>+<y>"}` the window size and position, saved on close
and restored on start. Git-ignored, per-machine. Safe to delete (window opens at
its default size).
## `data/icon.png`
The red-cross window icon, drawn at first run and written here so the `.desktop`
launcher can point at it. Git-ignored (regenerated). Delete to force a redraw.
+20 -12
View File
@@ -3,23 +3,31 @@
Running list so the same mistakes aren't repeated. Add to it whenever reality Running list so the same mistakes aren't repeated. Add to it whenever reality
contradicts an assumption. contradicts an assumption.
## The GUI does not run on the development Mac (2026-09-07) ## The macOS system Python can't run the GUI; use a Homebrew Python (2026-09-07)
The macOS **CommandLineTools** Python 3.9 (`/Library/Developer/CommandLineTools/ The macOS **CommandLineTools** Python 3.9 (`/Library/Developer/CommandLineTools/
.../python3.9`) ships **Tk 8.5.9**. `ttkbootstrap` 1.10.1 requires **Tk 8.6+**. .../python3.9`) ships **Tk 8.5.9**. `ttkbootstrap` 1.10.1 requires **Tk 8.6+**.
Symptoms: `TclError: couldn't recognize image data` (window icon) and
`TclError: unknown option "-style"` on `ttk.Scrollbar`.
Symptoms when trying to launch `App()` there: **Fix for local GUI testing:** `brew install python-tk@3.12` (pulls `tcl-tk`,
- `_tkinter.TclError: couldn't recognize image data` (ttkbootstrap's window icon gives Python 3.12 + **Tk 9.0**), then:
is a PNG; Tk 8.5 can't decode it), and
- `_tkinter.TclError: unknown option "-style"` on `ttk.Scrollbar`.
Consequences: ```bash
- `app.py` **cannot be smoke-tested through the GUI on this machine.** Logic is /opt/homebrew/bin/python3.12 -m venv venv
covered by a non-GUI script (constructs `DataStore`, exercises helpers and the venv/bin/pip install -r requirements.txt
worker with monkey-patched paths). venv/bin/python app.py
- The **Linux Mint target is unaffected** — its system Tk is 8.6. ```
- If GUI testing on macOS is ever needed, install a Homebrew
`python-tk@3.x` / `tcl-tk` combo, or use `pyenv` with `--with-tcltk`. `ttkbootstrap` 1.10.1 runs fine on Tk 9.0 in practice (verified: window builds,
Treeview sort, DateEntry, LabelFrame, custom styles all work).
- The **Linux Mint target** uses its own `python3-tk` (Tk 8.6+) and is
unaffected either way.
- `screencapture` from a non-GUI shell fails ("could not create image from
display") without Screen-Recording permission — automated screenshots of the
running app aren't available here; verify by building the widget tree and
driving it programmatically instead.
## `pandas` reads everything as strings by design ## `pandas` reads everything as strings by design
+24 -1
View File
@@ -188,9 +188,32 @@ tile pack — larger effort).
--- ---
## UI/UX review (2026-09-07)
Second review, focused on the interface. Items 112 implemented; 13 deferred.
| # | Change | Status |
|---|--------|--------|
| 1 | Larger base font + taller Treeview rows (`UI_FONT_SIZE`) | ✅ |
| 2 | Colour-coded status bar + 8 s auto-clear to a neutral state | ✅ |
| 3 | "Warteschlange leeren" asks for confirmation; more form spacing | ✅ |
| 4 | ▲/▼ arrow on the active sort column in Tab 2 | ✅ |
| 5 | Dropped the now-redundant `(JJJJ-MM-TT)` hint | ✅ |
| 6 | Tab 2: single **"Karte"** column (`✓` / red `fehlt`) instead of raw lat/lon | ✅ |
| 7 | Explicit "Auswahl entfernen" button + <kbd>Delete</kbd> key for queue rows | ✅ |
| 8 | Duplicate save shows a modal with the list, not a transient hint | ✅ |
| 9 | Persistent entry count (window title + Tab 2 hint line) | ✅ |
| 10 | "Karte öffnen" with no coordinates shows a dialog | ✅ |
| 11 | Remember window size & position (`data/window.json`) | ✅ |
| 12 | DRK-red header bar + runtime-drawn red-cross icon (`data/icon.png`) | ✅ |
| 13 | One-click "Hinzufügen & sofort speichern" | ⬜ deferred |
Full custom DRK-red *theme* (recolouring `primary` etc.) was **not** done — the
red header bar gives the branding without fighting ttkbootstrap's theme system.
## Suggested order of remaining work ## Suggested order of remaining work
1. Initial git commit (item 4), then decide on committing `orte.csv`. 1. Initial git commit (item 4), then decide on committing `orte.csv`.
2. Pinned deps + `run.sh` / `.desktop` launcher in the repo (item 5). 2. Pinned deps + `run.sh` / `.desktop` launcher in the repo (item 5).
3. Stable `id` column (item 7) and a modal duplicate prompt (item 8). 3. Stable `id` column (item 7 of the first review).
4. Formalize tests (item 10); offline map (item 9) only if offline use becomes real. 4. Formalize tests (item 10); offline map (item 9) only if offline use becomes real.
+23 -12
View File
@@ -1,6 +1,11 @@
# Overview what the app does # Overview what the app does
The app is a single window with a status bar at the bottom and two tabs. The app is a single window: a **DRK-red header bar**, two tabs, and a
**colour-coded status bar** at the bottom (grey = neutral, green = success,
orange = warning, red = error; transient messages fade back to neutral after
~8 s). The window title shows the total entry count, and the window remembers
its size and position between sessions (`data/window.json`). The UI font is
enlarged for readability (`UI_FONT_SIZE`).
## Tab 1 "Neuer Eintrag" (new entry) ## Tab 1 "Neuer Eintrag" (new entry)
@@ -12,28 +17,34 @@ then save the whole batch at once.
`TT.MM.JJJJ` and **validated** an invalid date is rejected with a dialog), `TT.MM.JJJJ` and **validated** an invalid date is rejected with a dialog),
city (autocomplete combobox), postal code (optional). Pressing `Return` in any city (autocomplete combobox), postal code (optional). Pressing `Return` in any
field, or the **+ Hinzufügen** button, adds the row to the queue. field, or the **+ Hinzufügen** button, adds the row to the queue.
The PLZ field only accepts digits.
2. **Queue table** shows date, city, PLZ, and a live coordinate column that 2. **Queue table** shows date, city, PLZ, and a live coordinate column that
updates from `⏳ wird gesucht…` to either `lat / lon` or `⚠ nicht gefunden` updates from `⏳ wird gesucht…` to either `lat / lon` or `⚠ nicht gefunden`
as the background geocoder works through the queue. as the background geocoder works through the queue.
- Left-click the `✕` cell to remove a row. - Select a row and press <kbd>Delete</kbd>, use the **"Auswahl entfernen"**
button, or click the `✕` cell.
- Right-click a row for *Löschen* / *Koordinaten erneut suchen*. - Right-click a row for *Löschen* / *Koordinaten erneut suchen*.
3. **Action bar** 3. **Action bar**
- **Alle speichern** appends every queued row to `orte.csv`. If a row looks - **Alle speichern** appends every queued row to `orte.csv`. If any row
like a duplicate of an existing entry (same date + city + PLZ) a hint is looks like a duplicate (same date + city + PLZ), a **modal** lists them and
shown in the status bar, but the row is **still saved**. asks whether to save anyway.
- **Leeren** discards the queue without saving. - **Warteschlange leeren** discards the queue (asks for confirmation).
- **Karte öffnen** regenerates `karte.html` and opens it in the browser. - **Karte öffnen** regenerates `karte.html` and opens it in the browser.
## Tab 2 "Einträge verwalten" (manage entries) ## Tab 2 "Einträge verwalten" (manage entries)
A table view of everything in `orte.csv`. A table view of everything in `orte.csv`.
- **Columns:** Datum, Ort, PLZ, and **Karte** a status column showing `✓` when
the row has coordinates or a red `fehlt` when it doesn't (the whole row is red
too). The raw lat/lon numbers live in the edit dialog, not this table.
- **Search box** live filter across date, city, and PLZ (substring match). - **Search box** live filter across date, city, and PLZ (substring match).
- **Sortable columns** click a header to sort; clicking again reverses. - **Sortable columns** click a header to sort; clicking again reverses. The
Default sort is by date, newest first. active column shows a ▲/▼ arrow. Default sort is by date, newest first.
Sorting by **Karte** groups the rows without coordinates together.
- **"Nur ohne Koordinaten"** toggle filters to entries that have no - **"Nur ohne Koordinaten"** toggle filters to entries that have no
coordinates yet. Such rows are also shown in **red** in the full list, and the coordinates yet. The hint line always shows the total count, plus how many are
hint line reports how many there are. missing coordinates.
- **Edit** double-click a row (or *Bearbeiten*) opens a modal dialog to change - **Edit** double-click a row (or *Bearbeiten*) opens a modal dialog to change
date (calendar picker, validated) / city / PLZ. Two ways to fix coordinates: date (calendar picker, validated) / city / PLZ. Two ways to fix coordinates:
a "Koordinaten automatisch neu suchen" toggle re-runs geocoding after saving, a "Koordinaten automatisch neu suchen" toggle re-runs geocoding after saving,
@@ -58,8 +69,8 @@ Generated by `generate_map()`:
(`5 + count / max_count * 20` px), colour is DRK red (`#CC0000`). (`5 + count / max_count * 20` px), colour is DRK red (`#CC0000`).
- Popup shows the city and the visit count; tooltip shows the city. - Popup shows the city and the visit count; tooltip shows the city.
- Rows with missing/blank coordinates are silently excluded. - Rows with missing/blank coordinates are silently excluded.
- If there are no usable coordinates at all, a `ValueError` is raised and shown - If there are no usable coordinates at all, a dialog says so (and the status
in the status bar. bar shows a warning).
> The generated HTML pulls Leaflet, jQuery and Bootstrap from CDNs, so the map > The generated HTML pulls Leaflet, jQuery and Bootstrap from CDNs, so the map
> needs an internet connection to render even though the data is local. > needs an internet connection to render even though the data is local.
+5 -4
View File
@@ -73,14 +73,15 @@ Type=Application
Name=DRK Blutspende Arbeitsorte Name=DRK Blutspende Arbeitsorte
Comment=Einsatzorte protokollieren und auf der Karte anzeigen Comment=Einsatzorte protokollieren und auf der Karte anzeigen
Exec=/home/USER/Apps/DRK_Blutspende_Orte/run.sh Exec=/home/USER/Apps/DRK_Blutspende_Orte/run.sh
Icon=/home/USER/Apps/DRK_Blutspende_Orte/icon.png Icon=/home/USER/Apps/DRK_Blutspende_Orte/data/icon.png
Terminal=false Terminal=false
Categories=Utility; Categories=Utility;
``` ```
Replace `USER` with the real username. Add any PNG as `icon.png` (the DRK logo Replace `USER` with the real username. The app writes a simple red-cross
works well). The entry then shows up in the Mint menu and can be pinned to the `data/icon.png` on first run; drop in the real DRK logo at that path if you have
panel or the desktop. one. The entry then shows up in the Mint menu and can be pinned to the panel or
the desktop.
## Python / Tk version ## Python / Tk version