diff --git a/.gitignore b/.gitignore index 19ced60..affda51 100644 --- a/.gitignore +++ b/.gitignore @@ -11,10 +11,12 @@ data/karte.html # Real data — kept out of version control for now (see docs/data-model.md) data/orte.csv -# Local backups and logs +# Local backups, logs and generated/per-machine files data/backups/ data/app.log data/app.log.* +data/window.json +data/icon.png # Editor / OS .vscode/ diff --git a/app.py b/app.py index cc7ab3e..585091e 100755 --- a/app.py +++ b/app.py @@ -6,6 +6,7 @@ Visualisiert Einsatzorte auf einer interaktiven Karte. from __future__ import annotations +import json import logging import math import os @@ -26,6 +27,7 @@ from geopy.geocoders import Nominatim from ttkbootstrap.constants import * import tkinter as tk +import tkinter.font as tkfont from tkinter import messagebox # ── Pfade ──────────────────────────────────────────────────────────────────── @@ -36,11 +38,19 @@ CSV_PATH = DATA_DIR / "orte.csv" MAP_PATH = DATA_DIR / "karte.html" BACKUP_DIR = DATA_DIR / "backups" 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"] #: Wie viele automatische Backups von orte.csv aufbewahrt werden. 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). GEOCODE_REGIONS = ["Baden-Württemberg", "Hessen"] @@ -419,9 +429,9 @@ def generate_map(store: DataStore) -> Path: f"{row['city']}
{count} Einsatz/Einsätze", max_width=200 ), tooltip=str(row["city"]), - color="#CC0000", + color=DRK_RED, fill=True, - fill_color="#CC0000", + fill_color=DRK_RED, fill_opacity=0.65, weight=2, ).add_to(m) @@ -579,10 +589,17 @@ class EditDialog(tk.Toplevel): class App(ttk.Window): + #: Statusmeldungs-Art → ttkbootstrap-Style der Statusleiste. + _STATUS_STYLES = { + "info": "secondary", "success": "success", + "warning": "warning", "error": "danger", + } + def __init__(self) -> None: super().__init__(themename="litera") - self.title("DRK Blutspende – Arbeitsorte") - self.minsize(760, 560) + self.minsize(820, 620) + self._scale_fonts() + self._install_icon() self.store = DataStore() self.geocoder = GeocoderWorker( @@ -593,17 +610,82 @@ class App(ttk.Window): self._queue: list[dict] = [] self._next_id = 0 + self._status_after_id: str | None = None 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) 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: """Einmalige Warnung, wenn der Standortdienst wiederholt nicht erreichbar ist.""" self._set_status( "Standortdienst nicht erreichbar – Koordinaten werden übersprungen. " - "Bitte Internetverbindung prüfen." + "Bitte Internetverbindung prüfen.", + "error", ) if not self._service_error_shown: self._service_error_shown = True @@ -618,12 +700,22 @@ class App(ttk.Window): # ── UI-Aufbau ───────────────────────────────────────────────────────────── 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) self._status_var = ttk.StringVar() - ttk.Label( + self._status_label = ttk.Label( self, textvariable=self._status_var, bootstyle="secondary", - anchor=W, padding=(12, 4), - ).pack(fill=X, side=BOTTOM) + anchor=W, padding=(12, 5), + ) + self._status_label.pack(fill=X, side=BOTTOM) # Notebook mit zwei Tabs self.notebook = ttk.Notebook(self) @@ -646,31 +738,33 @@ class App(ttk.Window): input_frame = ttk.LabelFrame(parent, text=" Eintrag ", padding=10) 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)) - 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 - ) + plz_ok = (self.register(lambda P: P == "" or (P.isdigit() and len(P) <= 5)), "%P") - 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_cb = ttk.Combobox(input_frame, textvariable=self._city_var, width=24) 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("", self._on_city_key) self.city_cb.bind("<>", 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() - plz_entry = ttk.Entry(input_frame, textvariable=self._plz_var, width=8) - plz_entry.grid(row=0, column=5, sticky=W, padx=(0, 14)) + plz_entry = ttk.Entry( + 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( - input_frame, text="+ Hinzufügen", bootstyle="success-outline", + input_frame, text="+ Hinzufügen", bootstyle="success", 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): w.bind("", lambda _e: self._on_add_row()) @@ -699,13 +793,16 @@ class App(ttk.Window): self.tree.bind("", self._on_tree_click) self.tree.bind("", self._on_queue_right_click) + self.tree.bind("", lambda _e: self._delete_selected_queue_rows()) # Aktionsleiste - action_frame = ttk.Frame(parent, padding=(4, 4)) + action_frame = ttk.Frame(parent, padding=(4, 6)) action_frame.pack(fill=X) ttk.Button(action_frame, text="Alle speichern", bootstyle="success", 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)) ttk.Button(action_frame, text="Karte öffnen", bootstyle="info", 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.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.heading("date", text="Datum", - command=lambda: self._sort_history("date")) - self.hist_tree.heading("city", text="Ort", - command=lambda: self._sort_history("city")) - self.hist_tree.heading("postal_code", text="PLZ", - command=lambda: self._sort_history("postal_code")) - self.hist_tree.heading("lat", text="Breitengrad", - command=lambda: self._sort_history("lat")) - self.hist_tree.heading("lon", text="Längengrad", - 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) + for key in cols: + self.hist_tree.heading( + key, text=self._hist_headings[key], + 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("postal_code", width=80, minwidth=55, stretch=False, anchor=CENTER) + 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) self.hist_tree.configure(yscrollcommand=sb2.set) self.hist_tree.pack(side=LEFT, fill=BOTH, expand=True) 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("", self._on_hist_double_click) self.hist_tree.bind("", self._on_hist_right_click) self._sort_col = "date" self._sort_asc = False # neueste zuerst + self._apply_sort_headings() # Aktionsleiste 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)) ttk.Button(action_frame, text="Löschen", bootstyle="danger-outline", 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, bootstyle="secondary").pack(side=RIGHT) @@ -788,6 +883,7 @@ class App(ttk.Window): df = self.store.df.copy() missing = df["lat"].map(_is_blank) | df["lon"].map(_is_blank) + total = len(df) total_missing = int(missing.sum()) query = self._search_var.get().strip().lower() @@ -801,7 +897,13 @@ class App(ttk.Window): if self._only_missing_var.get(): 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(): 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("city", ""), row.get("postal_code", ""), - row.get("lat", ""), - row.get("lon", ""), + "fehlt" if row_missing else "✓", ), ) + parts = [f"{total} Einsätze gespeichert"] if total_missing: - self._hist_hint_var.set( - f"{total_missing} Eintrag/Einträge ohne Koordinaten (rot) – " - f"Rechtsklick › Koordinaten suchen" + parts.append( + f"{total_missing} ohne Koordinaten (rot) – Rechtsklick › Koordinaten suchen" ) 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: if self._sort_col == col: @@ -831,6 +941,7 @@ class App(ttk.Window): else: self._sort_col = col self._sort_asc = True + self._apply_sort_headings() self._refresh_history() def _on_hist_double_click(self, event) -> None: @@ -907,8 +1018,8 @@ class App(ttk.Window): new_vals["lon"] = dlg.result["lon"] self.store.update_row(df_idx, new_vals) self._set_status( - f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}. " - f"Gesamt: {self.store.total_count()} Einträge." + f"Eintrag aktualisiert: {new_vals['date']} {new_vals['city']}.", + "success", ) self.city_cb["values"] = self.store.get_autocomplete_strings() @@ -923,11 +1034,12 @@ class App(ttk.Window): "lat": round(coords[0], 5), "lon": round(coords[1], 5), }) - self._set_status( - f"Koordinaten aktualisiert. Gesamt: {self.store.total_count()} Einträge." - ) + self._set_status("Koordinaten aktualisiert.", "success") else: - self._set_status("Koordinaten konnten nicht gefunden werden.") + self._set_status( + "Koordinaten konnten nicht gefunden werden – ggf. manuell eintragen.", + "warning", + ) self._refresh_history() def _on_hist_delete(self) -> None: @@ -945,9 +1057,11 @@ class App(ttk.Window): return indices = [int(iid) for iid in sel] self.store.delete_rows(indices) + self._update_title() self._refresh_history() 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 ───────────────────────────────────────────────── @@ -971,10 +1085,10 @@ class App(ttk.Window): date_str = parse_date(self._date_entry.entry.get()) if not city: - self._set_status("Bitte einen Ort eingeben.") + 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.") + self._set_status("Bitte ein gültiges Datum (JJJJ-MM-TT) eingeben.", "warning") messagebox.showwarning( "Ungültiges Datum", "Bitte ein gültiges Datum im Format JJJJ-MM-TT eingeben " @@ -996,7 +1110,9 @@ class App(ttk.Window): self._city_var.set("") self._plz_var.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: 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] if self.tree.exists(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: for row in self._queue: @@ -1058,6 +1184,12 @@ class App(ttk.Window): break 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() for item in self.tree.get_children(): self.tree.delete(item) @@ -1072,11 +1204,16 @@ class App(ttk.Window): dupes = self.store.find_duplicates(self._queue) if dupes: - names = ", ".join(f"{d['date']} {d['city']}" for d in dupes[:3]) - suffix = " u.w." if len(dupes) > 3 else "" - self._set_status( - f"Hinweis: Mögliche Duplikate ({names}{suffix}). Trotzdem gespeichert." - ) + listed = "\n".join(f" • {d['date']} {d['city']}" for d in dupes[:8]) + more = f"\n … und {len(dupes) - 8} weitere" if len(dupes) > 8 else "" + if not messagebox.askyesno( + "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) count = len(self._queue) @@ -1085,9 +1222,11 @@ class App(ttk.Window): self.tree.delete(item) self.city_cb["values"] = self.store.get_autocomplete_strings() + self._update_title() self._refresh_history() # Tab 2 sofort aktualisieren 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 ───────────────────────────────────────────────────────────────── @@ -1096,25 +1235,50 @@ class App(ttk.Window): try: path = generate_map(self.store) except ValueError as e: - self._set_status(str(e)) + messagebox.showinfo("Karte", str(e), parent=self) + self._set_status(str(e), "warning") return except Exception as e: 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 try: webbrowser.open(path.as_uri()) except Exception: 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 ───────────────────────────────────────────────────────── - 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_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: log.info("App wird beendet.") + self._save_window_state() self.geocoder.stop() self.destroy() diff --git a/docs/README.md b/docs/README.md index f8c13b0..aec4375 100644 --- a/docs/README.md +++ b/docs/README.md @@ -28,7 +28,7 @@ code) and plots them on an interactive map. - **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 - **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` - **Tests:** non-GUI smoke checks only (not yet a committed `tests/` suite) - **Version control:** git initialized 2026-09-07 diff --git a/docs/changelog.md b/docs/changelog.md index 3470be2..a5e12eb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -2,6 +2,32 @@ 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 + Delete 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 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 Non-GUI smoke tests (helpers, `DataStore` backup/atomic/recovery, geocoder -resilience) pass. **The GUI could not be run on the development Mac** — see -[dev-notes.md](dev-notes.md). +resilience) pass. The GUI now *can* be built and driven on the dev Mac via a +Homebrew Python with Tk 9 — see [dev-notes.md](dev-notes.md). diff --git a/docs/data-model.md b/docs/data-model.md index 8466572..479e238 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -59,3 +59,14 @@ closed. Rotating log file (512 KB × 3 generations). Git-ignored. Records saves, map generation, geocoding failures, and uncaught exceptions. + +## `data/window.json` + +`{"geometry": "x++"}` – 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. diff --git a/docs/dev-notes.md b/docs/dev-notes.md index b21712f..796722a 100644 --- a/docs/dev-notes.md +++ b/docs/dev-notes.md @@ -3,23 +3,31 @@ Running list so the same mistakes aren't repeated. Add to it whenever reality 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/ .../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: -- `_tkinter.TclError: couldn't recognize image data` (ttkbootstrap's window icon - is a PNG; Tk 8.5 can't decode it), and -- `_tkinter.TclError: unknown option "-style"` on `ttk.Scrollbar`. +**Fix for local GUI testing:** `brew install python-tk@3.12` (pulls `tcl-tk`, +gives Python 3.12 + **Tk 9.0**), then: -Consequences: -- `app.py` **cannot be smoke-tested through the GUI on this machine.** Logic is - covered by a non-GUI script (constructs `DataStore`, exercises helpers and the - worker with monkey-patched paths). -- 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`. +```bash +/opt/homebrew/bin/python3.12 -m venv venv +venv/bin/pip install -r requirements.txt +venv/bin/python app.py +``` + +`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 diff --git a/docs/improvements.md b/docs/improvements.md index e4b8c0e..aefc02b 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -188,9 +188,32 @@ tile pack — larger effort). --- +## UI/UX review (2026-09-07) + +Second review, focused on the interface. Items 1–12 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 + Delete 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 1. Initial git commit (item 4), then decide on committing `orte.csv`. 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. diff --git a/docs/overview.md b/docs/overview.md index d055316..f742748 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -1,6 +1,11 @@ # 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) @@ -12,28 +17,34 @@ then save the whole batch at once. `TT.MM.JJJJ` and **validated** – an invalid date is rejected with a dialog), city (autocomplete combobox), postal code (optional). Pressing `Return` in any 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 updates from `⏳ wird gesucht…` to either `lat / lon` or `⚠ nicht gefunden` as the background geocoder works through the queue. - - Left-click the `✕` cell to remove a row. + - Select a row and press Delete, use the **"Auswahl entfernen"** + button, or click the `✕` cell. - Right-click a row for *Löschen* / *Koordinaten erneut suchen*. 3. **Action bar** - - **Alle speichern** – appends every queued row to `orte.csv`. If a row looks - like a duplicate of an existing entry (same date + city + PLZ) a hint is - shown in the status bar, but the row is **still saved**. - - **Leeren** – discards the queue without saving. + - **Alle speichern** – appends every queued row to `orte.csv`. If any row + looks like a duplicate (same date + city + PLZ), a **modal** lists them and + asks whether to save anyway. + - **Warteschlange leeren** – discards the queue (asks for confirmation). - **Karte öffnen** – regenerates `karte.html` and opens it in the browser. ## Tab 2 – "Einträge verwalten" (manage entries) 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). -- **Sortable columns** – click a header to sort; clicking again reverses. - Default sort is by date, newest first. +- **Sortable columns** – click a header to sort; clicking again reverses. The + 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 - coordinates yet. Such rows are also shown in **red** in the full list, and the - hint line reports how many there are. + coordinates yet. The hint line always shows the total count, plus how many are + missing coordinates. - **Edit** – double-click a row (or *Bearbeiten*) opens a modal dialog to change date (calendar picker, validated) / city / PLZ. Two ways to fix coordinates: 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`). - Popup shows the city and the visit count; tooltip shows the city. - Rows with missing/blank coordinates are silently excluded. -- If there are no usable coordinates at all, a `ValueError` is raised and shown - in the status bar. +- If there are no usable coordinates at all, a dialog says so (and the status + bar shows a warning). > 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. diff --git a/docs/setup.md b/docs/setup.md index 838402b..9f060ce 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -73,14 +73,15 @@ Type=Application Name=DRK Blutspende – Arbeitsorte Comment=Einsatzorte protokollieren und auf der Karte anzeigen 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 Categories=Utility; ``` -Replace `USER` with the real username. Add any PNG as `icon.png` (the DRK logo -works well). The entry then shows up in the Mint menu and can be pinned to the -panel or the desktop. +Replace `USER` with the real username. The app writes a simple red-cross +`data/icon.png` on first run; drop in the real DRK logo at that path if you have +one. The entry then shows up in the Mint menu and can be pinned to the panel or +the desktop. ## Python / Tk version