Initial commit: Arbeitsorte-Logger + data-safety/robustness/input work

Existing app (single-file Tkinter/ttkbootstrap desktop tool for logging
blood-drive work assignments and mapping them) plus the first round of
improvements:

- Data safety: atomic CSV writes (tmp + fsync + os.replace), rotating
  backups in data/backups/ (startup + before every change, keep 20),
  fallback to empty/backup on missing/empty/corrupt orte.csv.
- Geocoder robustness: per-item try/except so the worker thread survives
  failures; GeocodingUnavailable + one-time "service unreachable" dialog.
- Input: DateEntry calendar picker with parse_date() validation; manual
  lat/lon fields in the edit dialog; Tab 2 highlights/filters rows without
  coordinates and adds a right-click "Koordinaten suchen".
- Logging to data/app.log; shared autocomplete helpers; config constants;
  map fit_bounds.

docs/ describes current state, architecture, data model, setup (Linux Mint),
and the full improvement roadmap. data/orte.csv is gitignored for now.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-07 18:28:28 +02:00
commit 37c9642877
11 changed files with 1809 additions and 0 deletions
+89
View File
@@ -0,0 +1,89 @@
# Architecture
Everything lives in [`app.py`](../app.py). There is no package structure.
## Components
| Class / function | Responsibility |
|------------------|----------------|
| `DataStore` | Owns `orte.csv`. Loads it into a pandas `DataFrame`, exposes read helpers (`get_map_data`, `get_autocomplete_strings`, `total_count`, `find_duplicates`) and write helpers (`append_rows`, `update_row`, `delete_rows`). All writes go through `_save_csv` → rotating backup + atomic write. All columns are read as strings. Falls back to an empty frame (or the newest backup) if `orte.csv` is missing/empty/corrupt. |
| `geocode_city()` | Pure function: `(city, plz) -> (lat, lon) | None`. Tries several Nominatim queries with a configurable regional bias (`GEOCODE_REGIONS`). Raises `GeocodingUnavailable` if *every* query failed on a service/network error (vs. simply not finding the place). |
| `GeocoderWorker` | A `threading.Thread` (daemon). Holds a FIFO list of `(row_id, city, plz, callback)` items guarded by a lock, woken by an `Event`. Processes one item per second, calls `geocode_city`, then invokes `callback` **from the worker thread**. Each item is wrapped in `try/except` so one failure never kills the thread; after `SERVICE_ERROR_THRESHOLD` consecutive service errors it fires the optional `on_service_error` callback. |
| `parse_date()` / `parse_coord()` / helpers | Module-level pure functions for normalizing user input (date → ISO, coordinate strings → float, `"City (PLZ)"` splitting). Shared by the entry tab and `EditDialog`. |
| `generate_map()` | Builds `karte.html` from `DataStore.get_map_data()` with Folium. |
| `EditDialog` | `tk.Toplevel` modal dialog for editing one row. Returns its result via `self.result`. |
| `App` | `ttkbootstrap.Window`. Builds the UI, owns the `DataStore`, the `GeocoderWorker`, and the in-memory `_queue` list for Tab 1. |
## Data flow
### Adding entries
```
user types -> _on_add_row()
-> append dict to self._queue
-> insert row into Tab-1 Treeview (coords = "⏳ wird gesucht…")
-> geocoder.enqueue(row_id, city, plz, self._geocode_done)
GeocoderWorker thread (1/sec):
coords = geocode_city(...)
-> self._geocode_done(row_id, coords) [worker thread]
-> self.after(0, self._apply_geocode_result, ...) [hop to UI thread]
-> mutate self._queue entry + update Treeview cell
user clicks "Alle speichern" -> _on_save_all()
-> DataStore.find_duplicates() (status-bar hint only)
-> DataStore.append_rows() -> CSV append + in-memory concat
-> clear queue, refresh Tab 2, update autocomplete
```
### Editing / deleting
`EditDialog` -> `DataStore.update_row(df_idx, values)` or
`DataStore.delete_rows(indices)` -> full `df.to_csv()` rewrite -> refresh.
## Threading model
- Tkinter is single-threaded; all widget access must happen on the main thread.
- The geocoder runs off-thread so the UI never blocks for the 1-second-per-item
rate limit.
- The **callback is invoked on the worker thread**, and every callback in
`App` immediately does `self.after(0, ...)` to marshal back onto the UI
thread. This is the load-bearing convention — any new callback must follow it.
(`on_service_error` does the same.)
- The worker catches every exception per item, so a bug in a callback or a
network error logs a traceback but never stops the queue.
- On window close, `_on_close()` calls `geocoder.stop()` (sets a flag + wakes
the event) and then `destroy()`. The thread is a daemon, so a missed stop
won't hang the process.
## Persistence model
- `orte.csv` is the single source of truth. It is read once at startup and then
kept in sync in memory.
- **Every** write (append, update, delete) rebuilds the full `DataFrame` and
goes through `DataStore._save_csv`:
1. `_make_backup()` copy the current file to
`data/backups/orte-YYYYMMDD-HHMMSS.csv` (skipped if byte-identical to the
newest backup); prune to `MAX_BACKUPS` (20).
2. `_write_atomic()` write to `orte.csv.tmp`, `fsync`, then `os.replace()`
onto `orte.csv` (atomic on POSIX). A crash mid-write leaves the old file
intact.
- A backup is also taken once at startup.
- Still **no file locking**: if the CSV is edited in another program while the
app is open, the next in-app save overwrites those changes (but the pre-write
backup captures them).
## Logging
`_setup_logging()` (called from `__main__`) attaches a `RotatingFileHandler` to
`data/app.log` (512 KB × 3). Logger name `arbeitsorte`, module-level `log`.
Records saves, map generation, geocoding failures, and any uncaught exception.
This is the file to ask for when diagnosing a problem on the user's laptop.
## External dependencies at runtime
| Dependency | Needed for | Offline behaviour |
|------------|-----------|-------------------|
| Nominatim (OSM) | geocoding new/edited entries | entries save with blank coords, shown as `⚠ nicht gefunden` |
| CDN (jsdelivr, jquery, cloudflare) | rendering `karte.html` | map page loads blank / unstyled |
| CartoDB tiles | map background | grey tiles |
Existing entries that already have coordinates do **not** need the network.