Skip to content

Multi-Device-Sync + Teilen/Import + Google-Kalender-Reservierungen - #27

Merged
Sven-MH merged 129 commits into
margenheld:masterfrom
Xveyn:feat/multi-device-sync
May 26, 2026
Merged

Multi-Device-Sync + Teilen/Import + Google-Kalender-Reservierungen#27
Sven-MH merged 129 commits into
margenheld:masterfrom
Xveyn:feat/multi-device-sync

Conversation

@Xveyn

@Xveyn Xveyn commented May 14, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Multi-Device-Sync via Google Drive (opt-in): Zeiteinträge und Mail-Vorlagen synchronisieren über einen versteckten Ordner (appDataFolder, Scope drive.appdata, non-sensitive). Pull beim App-Start, Push manuell oder beim Schließen. Konflikte werden als First-Class-Objekte gehalten und vom User manuell aufgelöst (A/B/eigener Wert).
  • Hide-Weekends-Toggle (bereits in 1.11.1 enthalten, hier mit gebündelt da auf demselben Branch): Wochenenden im Kalender optional ausblendbar.
  • Arbeitszeiten teilen & importieren: Einträge als JSON-Anhang per Gmail an andere verschicken (Empfänger direkt im Teilen-Dialog eingebbar); Import-Dialog mit Zeitraum-Filter und Konflikt-Modi.
  • Google-Kalender-Reservierungen (opt-in): zukünftige Arbeitszeiten pro Tag reservieren — ein eigenständiges Konzept neben den erfassten Ist-Zeiten — und mit einem wählbaren Google Kalender abgleichen.
  • Zusätzliche Features auf diesem Branch nachgeschoben (siehe CHANGELOG): Absender-E-Mail-Auto-Fetch, „Immer im Vordergrund", Minimize-to-Tray, themed Confirm/Success-Dialoge, Dark Titlebar Win11, Kalender-Spaltenbreiten-Vereinheitlichung, Dialog-Clamping, Tab-Toggle zwischen Monat- und Wochenansicht, gepinnte Fensterbreite über alle View/Weekend-Kombinationen, verständliche Offline-Fehlermeldung beim Senden/Teilen statt Traceback.
  • Bumpt auf v1.12.0.

🏷️ Release-Label: release:minor

Bitte vor dem Merge release:minor am PR setzen — neue Features ohne Breaking Changes. Version steht schon auf 1.12.0 in src/version.py. Ohne Label triggert der Release-Workflow nicht und es wird kein Tag/Installer-Release erzeugt. (Fork-User hat keine Permissions zum Label-Setzen.)

Was neu ist

Sync-Architektur

  • src/sync.py — pure Sync-Engine mit LWW-Merge pro Eintrag und pro Whitelist-Setting, Tombstones, Konflikt-Idempotenz, Resolution-Propagation
  • src/drive.py — Drive-API-Wrapper (OAuth mit kombinierten Scopes gmail.send + userinfo.email + drive.appdata, find/download/upload)
  • src/conflicts_store.py — lokale conflicts.json-Persistenz
  • src/dialogs/conflicts_dialog.py — Modal zur User-Resolution

Google-Kalender-Reservierungen

  • src/gcal.py — Google-Calendar-API-Wrapper (Event-CRUD, Kalenderliste, OAuth mit Scope-Union; Google-Imports lazy, damit die CI ohne requirements.txt durchläuft)
  • src/reservations.pyReservationStore, lokale reservations.json-Persistenz (eigenständiges Konzept, nicht Teil der Drive-Sync)
  • src/reservations_sync.py — pure merge_reservations (LWW, ohne Konflikt-Dialog) + reconcile_reservations-Orchestrator (pull → merge → push)
  • Reservierungs-Block im Tages-Dialog; Tage mit Reservierung im Kalender-Grid mit einem violetten Eck-Punkt markiert (kein eigener Zelltyp — die Reservierung wird nur angezeigt, wenn der Kalender-Abgleich aktiv ist)
  • „Google Kalender"-Bereich im Settings-Dialog (Aktivieren-Toggle + Kalender-Auswahl per Dropdown)
  • Der Google Kalender ist der geräteübergreifende Speicher: Push überschreibt, ein Wasserstand (last_calendar_sync_at) unterscheidet lokale Löschungen von Remote-Neuanlagen — keine Konflikt-Dialoge
  • Reconcile läuft bei App-Start (Fehler still geloggt) und nach jeder Reservierungsänderung (Fehler als Messagebox)
  • OAuth: gcal_enabled wird durch alle Service-Getter (mail.py/drive.py) gereicht, damit das geteilte token.json alle Scopes behält

Teilen / Import

  • src/share.py + src/dialogs/share_dialog.py — Einträge als JSON-Anhang per Gmail teilen; Empfänger direkt im Dialog eingebbar, optional als Standard speicherbar
  • src/dialogs/import_dialog.py — Import mit Zeitraum-Filter und Konflikt-Modi
  • „Arbeitszeiten importieren…" im Settings-Dialog (der Teilen-Empfänger wird direkt im Teilen-Dialog eingegeben, nicht in den Settings)

UI

  • Sync-Button + Status-Label im Header (nur sichtbar wenn Sync aktiv)
  • Konflikt-Markierung im Kalender (oranger Rand + Tooltip)
  • Sync-Sektion im Settings-Dialog mit Toggle und Konflikt-Übersicht
  • Themed Success-Popup nach Mail-Versand und themed Ja/Nein-Dialoge
  • Tray-Icon (Win/macOS) mit Anzeigen-/Beenden-Menü
  • „Immer im Vordergrund"-Toggle
  • Dark Titelleiste auf Windows 11
  • Kalender-Spaltenbreiten unabhängig vom Zellinhalt; Zeit-Font/Truncation-Tuning
  • Settings: Klick außerhalb eines Felds nimmt den Fokus weg
  • Dialog-Position wird an die Bildschirmgrenzen geklammert (taskbar-aware)
  • Push-on-close-Fehler werden als Messagebox sichtbar
  • Tab schaltet zwischen Monat- und Wochenansicht (Hauptfenster im Fokus)
  • Monatsansicht ohne Wochenende: keine sichtbar leere erste Zeile mehr bei Monaten mit Sa/So-Start
  • Header-Reihenhöhe bleibt beim View-Wechsel konstant (Toggle-Buttons springen nicht mehr vertikal)
  • Fensterbreite gepinnt: View- und Weekend-Toggle ändern die Breite nicht mehr (Pre-Warm aller 4 Kombinationen im Backbuffer beim Start, max-Breite gewinnt)

Mail-Pipeline

  • Absender-E-Mail wird automatisch über userinfo.email aus dem Token gezogen; „Aktualisieren"-Button im Settings-Dialog für Token-Reconsent
  • Schlägt „Monat senden" oder „Teilen…" mangels Internetverbindung fehl, erscheint eine verständliche „Keine Internetverbindung"-Meldung statt eines technischen Tracebacks; mail.is_offline_error erkennt Netzwerkfehler über die Exception-Kette. Andere Fehler behalten die Detail-Ausgabe (CLAUDE.md), der Trace bleibt im Logfile

Storage / Settings

  • Pro-Eintrag-Metadaten (modified_at, device_id, deleted als Tombstone)
  • Migration alter zeiterfassung.json auf neues Format (idempotent, mtime-basiert)
  • Settings bekommen _synced_meta für Per-Field-Tracking der Whitelist-Keys
  • Neue Settings-Keys gcal_enabled / gcal_calendar_id / last_calendar_sync_at; gcal_calendar_id reist über die Settings-Sync mit, damit alle Geräte denselben Kalender treffen

Doku

  • README.md — neue Sektionen „Multi-Device-Sync einrichten" und userinfo.email-Scope-Hinweis
  • docs/superpowers/specs/2026-05-14-multi-device-sync-design.md — vollständige Spec
  • docs/superpowers/plans/2026-05-14-multi-device-sync.md — Implementierungsplan
  • docs/superpowers/specs/2026-05-20-google-calendar-reservierungen-design.md + docs/superpowers/plans/2026-05-20-google-calendar-reservierungen.md — Spec & Plan der Kalender-Anbindung
  • docs/known-limitations.md — Tombstone-GC bewusst ausgelassen, YAGNI
  • CHANGELOG.md — vollständige Feature-/Change-Liste

Tests / CI

  • +63 neue Tests für den Multi-Device-Sync (test_sync.py, test_drive.py, test_conflicts_store.py, test_storage_migration.py, Erweiterungen in test_storage.py / test_settings.py / test_mail.py)
  • Google-Kalender-Reservierungen: neue Tests in test_gcal.py, test_reservations.py, test_reservations_sync.py
  • Offline-Fehlererkennung: Tests für mail.is_offline_error in test_mail.py
  • CI-Workflow installiert jetzt google-api-python-client, google-auth, google-auth-oauthlib
  • Gesamt: 317 passed, 1 skipped

Trade-offs

  • Optimistic-Locking via ETag/version wurde gedroppt — Drive API v3 hat etag entfernt und das If-Match-Pattern lässt sich mit googleapiclient nicht sauber bedienen. Konflikterkennung läuft stattdessen pro Eintrag über modified_at, was für ein 1-Datei-pro-User-Sync mit niedrigem Push-Volumen ausreichend ist.
  • Tombstones wachsen unbeschränkt (siehe docs/known-limitations.md). Größenordnung Kilobyte pro Jahr bei normalem Gebrauch.
  • Reservierungen nutzen den Google Kalender selbst als geräteübergreifenden Speicher (nicht die Drive-Sync). „Push überschreibt" heißt: manuelle Änderungen an App-Events direkt im Kalender werden beim nächsten Reconcile überschrieben — bewusst (Einweg App → Kalender).

Test plan

  • pytest — 317 passed, 1 skipped
  • Lokaler Build via python build.py + Install via Datei-Tausch in %LOCALAPPDATA%\Programs\Zeiterfassung\ (Stand Multi-Device-Sync)
  • Manueller End-to-End mit echtem Drive: Sync aktivieren → OAuth-Reconsent mit drive.appdata → Push → File erscheint im appDataFolder (per Drive-API verifiziert)
  • Manueller Zweit-Gerät-Test (Konflikt provozieren, Resolution propagieren) — vor Release nachholen
  • Manueller End-to-End Google Kalender: Feature in den Einstellungen aktivieren → Kalender wählen → Reservierung anlegen → Termin erscheint im Google Kalender; Ändern/Löschen spiegelt sich; nach App-Neustart wird die Reservierung aus dem Kalender gepullt — vor Release nachholen
  • Manueller Offline-Test: WLAN trennen → „Monat senden" / „Teilen…" klicken → „Keine Internetverbindung"-Meldung erscheint statt Traceback — vor Release nachholen
  • CI grün auf GitHub Actions

Aktivierung im Test

Default: Sync und Google-Kalender-Abgleich sind deaktiviert. Bestehende User merken nichts. Wer testen will: Settings öffnen → „Mit Google Drive synchronisieren" bzw. „Reservierungen mit Google Kalender abgleichen" anhaken → einmalig OAuth-Reconsent (siehe README für Cloud-Console-Setup mit drive.appdata-, userinfo.email- und Calendar-Scopes).

🤖 Generated with Claude Code

Xveyn and others added 30 commits May 14, 2026 15:11
Pre-existing Pylance reportArgumentType warnings on .grid(**kw) because
Pylance inferred kw as dict[str, int | str] from the heterogeneous dict
literal and could not narrow to grid_configure's per-parameter types.
Bei show_weekend=False blieben die Spalten 5 und 6 mit weight=1 konfiguriert
und absorbierten den vom Header/Footer geforderten Extra-Platz — sichtbar
als leerer Streifen rechts neben Fr. _get_inactive_grid setzt jetzt
weight=0 für nicht sichtbare Spalten, gleiche Behandlung im _refresh-
Resize-Pfad fuer den neuen Inactive-Frame.
Das email-Setting (UI-Label "Absender") war toter Code: kein Modul außer
dem Settings-Dialog selbst hat es jemals gelesen. send_email() in mail.py
setzt keinen From:-Header, der Absender wird zwingend vom OAuth-Token
über userId='me' bestimmt. Ein gesetzter Wert konnte den Sender also
gar nicht beeinflussen.

- DEFAULTS-Key entfernt
- Settings-Dialog-Zeile (label + entry + updates-Eintrag) entfernt
- Tests, die email als generisches String-Setting nutzten, auf
  recipient umgestellt; neuer Regressionstest, der dokumentiert dass
  ein altes email-Feld in settings.json still ignoriert wird
Header() assignment auf message subject und tk.Tk.report_callback_exception
mit pyright ignores; in theme.py _LabelButton-Subklasse und _ToggleColors
TypedDict für getypte Attribute/Kwargs, width-Default 0 statt None.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Spec für Google-Drive-basierten Multi-Device-Sync (appDataFolder,
LWW pro Eintrag, Konflikt-Objekte mit User-Resolution). Plus
known-limitations.md mit Hinweis auf fehlende Tombstone-GC.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Truncation per _truncate reicht; wraplength entfernen, damit der gekürzte
Name nicht zusätzlich umgebrochen wird.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
5-phase plan: storage/settings foundation, sync engine, Drive client,
UI wiring, robustness polish. TDD with frequent commits per task.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Re-add the existence guard in delete() so calling it on a never-saved
date is a no-op rather than persisting a phantom tombstone.
Add _REQUIRED_ENTRY_KEYS constant and validation in apply_merge() so
malformed sync entries are rejected at the boundary instead of
corrupting _data silently.
Tighten test_delete_nonexistent to assert no phantom entry leaks;
add two new tests for apply_merge validation.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Xveyn and others added 16 commits May 20, 2026 10:48
…tence

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
reconcile_reservations re-reads the store right before apply_reconciled
and rebases reservations added/changed during its network phase, so a
concurrent save (e.g. the startup reconcile overlapping a user save) is
not lost by the blind replace. Also guards the settings-dialog calendar-id
persistence behind a loaded calendar list.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Xveyn Xveyn changed the title Multi-Device-Sync via Google Drive + hide-weekends Multi-Device-Sync + Teilen/Import + Google-Kalender-Reservierungen May 21, 2026
Xveyn and others added 5 commits May 21, 2026 10:17
„Monat senden" und „Teilen…" zeigten bei fehlender Internetverbindung
den rohen Python-Traceback im Fehler-Popup. Neuer Helfer
`mail.is_offline_error` erkennt Netzwerkfehler anhand der Exception-Kette;
der Sendepfad zeigt dann „Keine Internetverbindung" statt der kryptischen
Detail-Ausgabe. Echte Fehler behalten den Traceback (CLAUDE.md); der Trace
landet ohnehin weiter im Logfile.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Eine Reservierung aendert den Zelltyp nicht mehr - sie wird immer nur
als kleiner violetter Eck-Punkt auf die ohnehin gebaute Zelle gelegt.
Nur-Reservierungs-Tage sehen damit aus wie ein leerer Tag (bzw.
Feiertag) mit Punkt statt einer violetten Vollzelle.

- _build_reservation_cell entfernt
- Eck-Punkt als Canvas-Oval statt kaum sichtbarem Text-Bullet
- _empty_hover faerbt den Punkt beim Hover mit
- Feiertags-Tooltip geht bei Ist-Zeit+Reservierung+Feiertag nicht mehr
  verloren (zweite Tooltip-Zeile)

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bei deaktiviertem Google-Kalender-Sync (gcal_enabled=false) wurden
Reservierungen trotzdem im Kalender als Eck-Punkt gerendert und waren
per Linksklick im Tages-Dialog setzbar. Neuer Helper
_reservations_active() gated Grid-Rendering und Dialog-Aufruf
einheitlich auf gcal_enabled.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der Teilen-Dialog verwaltet den Empfänger bereits vollständig
(vorbefülltes Feld + "Als Standard speichern"-Checkbox). Das separate
Settings-Feld war eine zweite, verwirrende Pflegestelle für dieselbe
Adresse (share_recipient). Der settings-Key bleibt erhalten, nur das
Dialog-Feld entfällt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Das per dialog.after() verzögerte except-Lambda griff frei auf die
except-Variable `e` zu. Python 3 löscht die am Ende des except-Blocks
(impliziter del), sodass beim verzögerten Ausführen ein NameError statt
der gedachten Fehlermeldung "Kalenderliste nicht verfügbar" auftrat.
e/tb werden jetzt als Default-Argumente ans Lambda gebunden.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Xveyn
Xveyn force-pushed the feat/multi-device-sync branch from 2f365f8 to 3da0010 Compare May 21, 2026 13:32
Xveyn and others added 5 commits May 21, 2026 15:34
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der 1.13.0-Block (Reservierungen, Google-Kalender, Offline-Meldung)
wandert in den 1.12.0-Block — master ist bei 1.11.0, es gab kein
1.13.0-Release. Dabei zwei veraltete Beschreibungen korrigiert:
Reservierungen sind ein violetter Eck-Punkt (keine violetten
Vollzellen), und das entfernte Feld „Teilen mit:" wird nicht mehr
genannt.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`wm_maxsize()` liefert auf Windows nur die Arbeitsfläche des Primärmonitors,
sodass das Clamping einen über `parent` geöffneten Dialog wieder auf den
ersten Bildschirm gezogen hat — auch wenn das Hauptfenster auf Monitor 2
saß.

Neuer Helper `_parent_workarea(parent)`: ermittelt auf Windows die
Arbeitsfläche des Monitors, auf dem der Parent liegt, über
`MonitorFromPoint` mit dem Bildschirm-Mittelpunkt des Parent. Tk's
`winfo_id()` liefert auf Windows den Client-HWND (nicht den WM-Frame) und
ist als Lookup-Anker weniger robust; `MonitorFromPoint` arbeitet direkt
auf den Bildschirmkoordinaten aus `winfo_rootx/y`.

Explizite ctypes-Signaturen für `MonitorFromPoint` und `GetMonitorInfoW`:
ohne `restype` ist der Default `c_int` (32 Bit), und ein HMONITOR auf
64-Bit-Windows kann darüber liegen — der Handle wäre dann truncated und
`GetMonitorInfo` schlüge still fehl, sodass der Fallback auf den
Primärmonitor zurückgriff (genau der bisherige Bug).

macOS/Linux: unverändert auf `wm_maxsize()` als Fallback, weil
Multi-Monitor-Workarea dort nicht ohne externe Lib aus Tk abrufbar ist.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Der Settings-Dialog ist mit der Sync- und Google-Kalender-Sektion so
hoch geworden, dass auf 1080p-Monitoren der Speichern-Button unter den
Bildschirmrand rutschen konnte — selbst flush an die Bildschirmkante
gerückt.

Beide Section-Header werden klickbar mit ▶/▼-Indikator. „Mail-Vorlage"
startet eingeklappt (Block wird selten geändert, spart am meisten Höhe);
„Gmail-Zugangsdaten" bleibt default ausgeklappt, da Empfänger/Name/
Bundesland in derselben Section liegen und häufig sichtbar gebraucht
werden.

Helper `_section_header(title, row, top_pad)`: erzeugt Header + Toggle.
Caller hängt jedes zur Section gehörende Widget an die zurückgegebene
`widgets`-Liste an. Beim Collapse wird pro Widget gemerkt, ob es
zuletzt im Grid lag — damit eine ohnehin eingeklappte Sub-Section
(z.B. „Standardzeiten:") beim Re-Expand der Eltern-Section nicht
versehentlich aufgeht.

Wir nutzen `winfo_manager() == "grid"` statt `winfo_ismapped()`,
weil letzteres vor dem ersten Window-Mapping False liefert. Beim
initialen `mv_toggle()` (Default-Einklappen vor `center_dialog_on_parent`)
hätte ismapped sonst alle Widgets als „nicht sichtbar" eingestuft, kein
grid_remove() ausgeführt — Header-Pfeil ging auf ▶, der Inhalt blieb
trotzdem sichtbar, und der User hätte zweimal klicken müssen, bis
Header und Inhalt sync sind. `winfo_manager()` ist mapping-unabhängig.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Wenn `ZEITERFASSUNG_DATA_DIR` gesetzt ist, wird der base_path daher
übernommen — vor der Frozen/Script-Verzweigung.

Use case: Dev-Setups, in denen `python -m src.main` aus dem Repo
denselben Daten-Ordner nutzen soll wie die installierte App. Hard-
oder Symlinks scheitern an dieser Stelle, weil alle Stores
(`storage`, `settings`, `reservations`, `conflicts_store`) per
`os.replace(tmp, target)` atomar speichern und dabei den
Verzeichniseintrag durch eine neue Datei ersetzen — der Link bricht
beim ersten Save.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@Sven-MH Sven-MH added the release:minor Bump minor version on merge label May 26, 2026
@Sven-MH
Sven-MH merged commit 8514349 into margenheld:master May 26, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

release:minor Bump minor version on merge

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants