Zum Hauptinhalt springen

Offline-Fähigkeit als PWA (OP-OFFLINE-1)

Status: gebaut — Stufe B (PWA-Shell + Read-Offline, PR B) ✅ · Stufe C (Foto-Upload-Queue, drei Varianten, PR C) ✅; Voll-Offline-Write bewusst offen (eigener OP, s. Stufen-Tabelle) · Bezug: OP-OFFLINE-1 (Owning-OP) · OP-R4-1 (Foto-Varianten) · OP-QS-GATE · OP-AUDIT-1 · Zielgruppe: IT-Dev/Architektur · IT-Ops · DevOps · True North: direkt Frage 2 (Wo stehen die Fahrzeuge?) — auch bei wackliger Werkstatt-Verbindung bleibt der letzte bekannte Stand sichtbar.

Kernaussage. Taktano wird eine PWA (Progressive Web App)keine native App nötig. Nach einmaligem Laden ist die App installierbar (Home-Screen auf Werkstatt-Tablets/Handys) und offline lauffähig: ein Service Worker cached die App-Shell, ein IndexedDB-Snapshot zeigt den letzten bekannten Stand (nur Ansicht; Offline-Schreiben von Aktionen ist bewusst nicht im Scope — Folge-Stufen, s. u.). Fotos sind bei funktionierender IndexedDB-Queue geschützt (persistente Upload-Queue, idempotenter R2-Key; schlägt die Persistenz fehl, meldet der Upload sofort statt still zu verlieren). Begründung gegen eine native App: die Infrastruktur ist bereits PWA-tauglich (HTTPS, statische Assets über den Worker), ein Web-Deploy bedient alle Plattformen ohne App-Store-Pflege, und das Datenmodell (Live-State via WebSocket) passt zum „nach Laden weiterarbeiten"-Muster.

Stufen (bewusst geschnitten)

StufeInhaltStatus
B — PWA-Shell + Read-OfflineService Worker (App-Shell-Cache), Web-Manifest (installierbar), IndexedDB-State-Snapshot, Offline-Banner („nur Ansicht")✅ gebaut (PR B)
C — Upload-QueueBild-Uploads offline puffern (IndexedDB) + bei Reconnect idempotent nachsenden; 3 Varianten (Thumbnail · verkleinert · Original), Thumbnail-priorisiert✅ gebaut (PR C + Varianten)
Voll-Offline-Writebeliebige Aktionen offline absetzen → Idempotenz-Keys + Reconnect-Replay + Konflikt-Handling gegen die DO-Wahrheitoffen (eigener OP, groß)

Architektur (Stufe B)

  • Service Worker: Angular Service Worker (@angular/service-worker), Konfig client/ngsw-config.json (App-Shell prefetch; Fonts/Bilder lazy; navigationUrls schließt /api/** aus, damit kein index.html-Fallback für API-Routen). Registrierung in client/src/app/app.config.ts via provideServiceWorker(..., { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000' })nur im Produktions-Build aktiv (kein SW-Cache stört die Dev-HMR). Build erzeugt ngsw-worker.js
    • ngsw.json; der Worker serviert sie als statische Assets (dist/leitstand/browser).
  • Manifest + Icon: client/public/manifest.webmanifest + client/public/icon.svg (Marken-Icon als SVG = everything-as-code, kein Binär-Master — CLAUDE.md). theme_color/background_color liegen außerhalb des No-Hex-Gates (scripts/check-no-hex.sh scannt nur client/src/app + styles.css), daher literale Brand-Hex erlaubt (konsistent zum bestehenden index.html-theme-color).
  • Read-Offline-Cache: StateCacheService (client/src/app/services/state-cache.service.ts) legt den zuletzt empfangenen LeitstandState als einzelnen Snapshot in IndexedDB ab (kein Verlauf — den trägt das Audit-Log, OP-AUDIT-1). LeitstandService persistiert bei jedem state-Broadcast und hydriert beim Start aus dem Cache, solange noch kein Live-Broadcast kam (Broadcast hat Vorrang). Signale lastStateAt + hydratedFromCacheofflineStale speist das Offline-Banner in der Shell.
  • Read-only offline (hart durchgesetzt): LeitstandService.send() verwirft Schreib-Kommandos bei !connected(). So landet offline nichts in PartySockets flüchtiger In-Memory-Queue — die sonst beim Reconnect halb-abgesetzte, nicht-idempotente Kommandos ohne Konflikt-Prüfung nachfeuern würde (und bei Reload/Tab-Schließen ohnehin verloren wäre). Das Banner macht „nur Ansicht" transparent. Eine persistente, idempotente Offline-Pufferung von Aktionen ist bewusst ein Folge-OP (s. u.).
  • Cache-Versionierung: der Snapshot trägt eine SCHEMA-Version; load() verwirft Alt-/inkompatible Snapshots (kein Hydrieren veralteter LeitstandState-Shapes nach Vertrags-Änderungen). Bewusst getrennt von APP_VERSION — nicht jeder Release ändert das State-Shape (sonst wäre der Cache nach jedem Deploy leer).

Architektur (Stufe C — verzögerter Foto-Upload)

Fotos sind der eine Schreibpfad, der offline erhalten bleiben muss (der Werker erfasst den Zustand vor Ort, auch bei Funkloch). Statt des Read-only-send()-Guards greift hier eine persistente Queue:

Drei Varianten je Foto (OP-R4-1) — Thumbnail-priorisiert

Je Foto werden drei Varianten erzeugt und synchronisiert: thumb (256 px, q0.70) · display (1024 px, q0.82, „verkleinert" = Arbeitsvariante) · original (volle Auflösung = die Datei selbst). erzeugeFotoVarianten() (foto-kompression.ts) dekodiert einmal (createImageBitmap, EXIF-Orientierung) und rendert daraus die zwei Skalierungen; best-effort: kein Bild / Decode-Fehler → alle drei = Originaldatei.

Priorität absteigend: thumb → display → originalüber ALLE wartenden Fotos hinweg: der Drain lädt erst die Thumbnails aller Fotos, dann die verkleinerten, dann die Originale, damit die UI überall schnellstmöglich ein Vorschaubild hat. Download ist ebenso thumb-first (s. u.).

  • OfflineFotoQueueService (client/src/app/services/offline-foto-queue.service.ts) — IndexedDB (taktano-offline-fotos, Store queue, keyPath fotoId) nach dem StateCacheService-Muster (best-effort, Fehler als boolean gemeldet). Speichert je Eintrag die drei Varianten-Blobs mit eigenen uploaded/registered-Flags, bis alle drei durch sind; count-Signal → Shell-Badge „N warten", eintraege-Signal → Popover.
  • Reine Zustandsmaschine (client/src/app/services/offline-foto-queue.ts, Angular-frei → Node-Selbsttest test:fotoqueue): naechsterVariantenSchritt(variante, connected) = upload → register → fertig je Variante (warten wenn WS zu); fotoEintragStatus(entry, connected) liefert die höchstpriorisierte offene Variante + Schritt (für die Status-Anzeige); fotoEintragFertig(entry) = alle drei registriert → löschen. FOTO_VARIANTEN_PRIO = ['thumb','display','original']. Die Flags machen den Replay idempotent.
  • Enqueue + Drain in LeitstandService: uploadFoto() erzeugt die drei Varianten und legt den Eintrag in die Queue (löst sofort auf) + triggert drainFotoQueue(). Der Drain iteriert Varianten AUSSEN, Einträge INNEN (Prioritäts-Reihenfolge über alle Fotos) und führt je (Variante, Eintrag) den nächsten Schritt aus (HTTP putFoto mit variante / WS send foto.variante). Trigger: Socket-open (initial
    • Reconnect) + Window-online-Event + State-Broadcast (= Registrierungs-Ack, s. u.) + nach jeder Erfassung.
  • Fehler-Klassifizierung (kein stiller Verlust, kein Endlos-Retry):
    • Persistenz (IndexedDB): add()/put()/update() liefern boolean. Scheitert das Puffern beim Erfassen (kein IndexedDB / Private-Modus / Quota voll), wirft uploadFoto() → das Detail-Panel meldet „Foto nicht gespeichert" (Warn-Toast) statt fälschlich „erfasst". Die Queue hält die einzige Kopie — ein geschluckter Fehler = Datenverlust. Scheitert ein update() mitten im Drain, wird nicht weitergeschoben (das In-Memory-Flag liefe sonst dem persistierten Stand voraus → Replay verlöre den Schritt).
    • HTTP-Upload: transient (offline / Netz / 5xx / 408 / 429) → Eintrag bleibt liegen, nächster Drain versucht erneut. Permanent (4xx, z. B. abgelehnt/zu groß/Auth) → Eintrag entfernt + zentral gemeldet (fotoUploadFehler → Shell-Toast), sonst hinge er endlos in der Queue.
  • Registrierung ack-getriggert (kein Orphan bei verpuffter WS-Meldung): die registered-Flags werden nicht blind nach send() gesetzt — eine über einen abreißenden Socket verpuffte foto.variante- Meldung würde die Variante sonst als erledigt markieren, obwohl der Server sie nie verarbeitet hat (R2-Objekt läge da, am Auftrag hinge nichts). Stattdessen gilt eine Variante erst als bestätigt, wenn der Server ihren Key in den State zurückspielt (fotoVarianteImState(fotoId, variante) prüft thumbR2Key/r2Key/originalR2Key an teilschritte[].fotos[]); der State-Broadcast triggert den nächsten Drain. send() ist idempotent (Server-Handler ist ein Upsert nach id+variante).
  • Idempotenz server-seitig: /api/foto/upload leitet den R2-Key deterministisch aus der fotoId ab, je Variante mit eigenem Pfad: fotos/{auftrag}/{instanz}/{perspektive}/thumb/{fotoId} · …/{fotoId} (display) · …/original/{fotoId} — ein Reconnect-Replay überschreibt denselben Key (kein Orphan). Der WS-Handler foto.variante legt den FotoAttachment beim ersten Eintreffen an (i. d. R. der Thumbnail, da priorisiert) und hängt die weiteren Varianten-Keys an (Upsert nach id). Schema: FotoAttachment.thumbR2Key additiv (SCHEMA_VERSION 17, kein Transform — fehlt ⇒ kein Thumbnail).
  • Foto-Pflicht (OP-QS-GATE): offline erfasste Fotos erfüllen die Pflicht erst nach Sync server- seitig — korrekt, denn abholen (die Übergabe) ist offline ohnehin gesperrt (Read-only, Stufe B).
  • Download thumb-first (OP-R4-1): die Foto-Kacheln im Detail-Panel laden die 256px-Variante (thumbR2Key, loading="lazy") — die verkleinerte 1024px-Variante erst beim Öffnen der Kachel, das Original nur über die „HQ"-Marke. Minimaler R2-Egress, schnellstes Rendern.
  • Queue-Detailsicht (Popover): das Footer-Badge „N warten" ist klickbar → kompaktes Popover mit je wartendem Foto (Thumbnail via Objekt-URL, Auftragsnummer · Perspektive, Status welche Variante lädt/ registriert/wartet, Fortschritt N/3). OfflineFotoQueueService.eintraege (Signal) speist die Liste; die Objekt-URLs (aus dem thumb-Blob) werden per Effect synchron zur Queue gehalten und beim Entfernen/Destroy freigegeben (kein Leak). Zahlt auf True North #2 ein (Transparenz „wo hakt der Sync").

Verbindungs-Wiederherstellung + Auto-Update (v0.147.0, VerbindungService)

Problem. Der SW cached die App-Shell (navigationUrls: "/**") → ein normaler Reload lädt Dokument

  • JS aus dem Cache und geht nicht zum Server. Damit kann er weder eine abgelaufene Cloudflare-Access-Session (die App ist per Golden Rule „Zero Trust vor Public" Access-gated; nach ~24 h läuft die Session ab → WS-Upgrade 401) noch einen frisch deployten Client einspielen — der WebSocket bleibt hängen, und der Offline-Cache verdeckt die Access-Umleitung (der Nutzer sieht die gecachte Shell statt der Access-Anmeldung). Ein manueller Hard-Reload umgeht den SW und behebt beides — aber darauf darf sich der Betrieb nicht verlassen.

Lösung (client/src/app/services/verbindung.service.ts).

  • „↻ Erneut verbinden" im Offline-Banner → hartNeuLaden(): SW-umgehender Neustart — hebt die SW-Registrierung(en) auf und lädt neu → Dokument frisch vom Server → Access-Re-Login / neuer Client → WS verbindet. Der SW re-registriert sich beim nächsten erfolgreichen Laden (Offline-Fähigkeit kehrt zurück). Nur bei vorhandenem Netz (navigator.onLine) — sonst ehrlicher Toast „kein Netz", weil PartySocket bei echtem Offline ohnehin automatisch reconnectet (kein Cache-Nuke ohne Nutzen).
  • SwUpdate-Auto-Update: bei Netz-Rückkehr (online) / Tab-Fokus (visibilitychange) checkForUpdate(); bei VERSION_READY erscheint das Banner „Neue Version — neu laden" (activateUpdate() + reload). Behebt den Stale-Client-Fall proaktiv (bisher aktivierte der SW eine neue Version erst, wenn alle Tabs geschlossen waren).

Offline-Stand verwerfen + komplett neu synchronisieren (v0.155.0)

Problem. Der Server ist erreichbar (Ping/Access ok), aber der lokale Offline-Datenbestand passt nicht mehr zum Server-Datenmodell — z. B. nach einem Schema-Sprung der DO-Persistenz, oder der Client hängt an einem Cache, der sich nicht sauber weiter-synchronisieren lässt. Gewünscht (PO): „dann biete an, den lokalen 'offline'-Datenbestand zu verwerfen und einmal komplett neu vom Server zu synchronisieren."

Lösung — drei Bausteine.

  • Schema-Broadcast (Server). Der State trägt die Daten-Schema-Version schemaVersion (SCHEMA_VERSION der DO-Persistenz; DTO in server/party/leitstand.ts, gesetzt in snapshot()). Der Client stempelt jeden Snapshot mit dieser serverSchema (StateCacheService.save/load) → der Offline-Cache ist selbstbeschreibend; ein zum aktuellen Server-Datenmodell inkompatibler Stand ist erkennbar (und bleibt für den künftigen Voll-Offline-Write als Kompatibilitäts-Signal nutzbar).
  • Stiller Selbstschutz (Client). Die Cache-SCHEMA-Konstante (StateCacheService) verwirft bei inkompatiblem LeitstandState-Shape Alt-Snapshots beim load() — es wird nie ein veralteter Shape hydriert. Sie wird erhöht, sobald das State-Shape ein neues Pflichtfeld bekommt — mit v0.155.0 auf 2 [schemaVersion wurde Pflicht → vor-0.155.0-Snapshots ohne das Feld werden still verworfen]. Und da der DO beim Verbinden immer den vollen, autoritativen State schickt, heilen Daten bei Reconnect ohnehin automatisch. Dieser Pfad läuft still (kein Nutzer-Dialog) — er ist immer sicher.
  • Bewusste Nutzer-Aktion „↻ Neu synchronisieren". VerbindungService.neuSynchronisieren() verwirft den lokalen View-Cache (StateCacheService.clear()) und macht dann hartNeuLaden() → Dokument frisch vom Server, DO liefert den vollen State neu. Zwei Zugänge: Sekundär-Link im Offline-Banner und Eintrag im ⚙-Menü (Abschnitt „Verbindung", „Offline-Stand verwerfen"). Wird nie automatisch ausgelöst (kein gültiger Cache ungefragt verworfen → immer nur anbieten). Erst prüfen, dann verwerfen: neben navigator.onLine läuft eine echte Same-Origin-Erreichbarkeits-Probe (HEAD/no-store; Access-Redirect/Auth-Status 3xx/4xx = „Server lebt, Reload holt den Login"; nur Netzwerkfehler/5xx = unerreichbar) — sonst nähme ein Aussetzer dem Nutzer Cache und App-Shell, ohne neu laden zu können; und clear() meldet Erfolg/Misserfolg (boolean), bei Misserfolg kein Reload (kein Schein-Resync, ehrlicher Toast). Die persistente Foto-Upload-Queue (taktano-offline-fotos) bleibt unberührtclear() löscht nur den View-State-Key leitstand, nie die einzige Kopie noch nicht hochgeladener Fotos.

Bewusst NICHT

  • Voll-Offline-Write (beliebige Aktionen offline absetzen) — kategorisch ausgeschlossen (PO-Entscheidung 2026-07-26). Kein „später/Backlog", sondern bewusst außerhalb des Produkt-Scopes. Begründung: der Durable Object ist die einzige Quelle der Wahrheit und live über WebSocket verbunden; offline-abgesetzte Schreibaktionen bräuchten Idempotenz-Keys, Reconnect-Replay und Konflikt-Auflösung gegen den autoritativen DO-State (was, wenn der Auftrag zwischenzeitlich storniert/anders getaktet wurde?) — eine dauerhafte, große Komplexität (Wartung, Fehlerfläche), die dem Leitprinzip Einfachheit widerspricht. Sie zahlt zudem auf keine der drei True-North-Fragen ein: Auslastung/Standort/freie Kapazität sind Leitstand-Sichten, die Server-Autorität und die harten Gates (QS/Vier-Augen G-4, Live-Taktung) ohnehin voraussetzen. Der einzige gerechtfertigte Offline-Write bleibt der enge, idempotente Foto-Upload (deterministischer R2-Key aus fotoId → kein Doppel/Orphan). Sollte je ein echter Werkstatt-Bedarf für Offline-Schreiben auftauchen, ist das ein neuer, eigens zu begründender OP — nicht die Fortsetzung dieses.
  • Background Sync (Uploads nach Tab-Schließen) → eigener, offener Punkt OP-OFFLINE-2 (der Foto-Queue-Drain läuft heute nur in-page; ein Service-Worker-sync-Handler ließe Uploads auch bei geschlossenem Tab abschließen). Optionales Nice-to-have, klar getrennt vom kategorisch ausgeschlossenen Voll-Offline-Write oben.
  • Raster-PNG-Icons (192/512): optionaler Folge-Schritt für maximale Install-Abdeckung. Aktuell SVG-only (icon.svg, sizes:"any", any+maskable) — Chromium akzeptiert SVG-Manifest-Icons, und der Toolchain hat keinen SVG→PNG-Rasterizer; der SVG bleibt everything-as-code-Master. Falls eine Launcher-/OS-Variante doch Raster verlangt, PNGs aus icon.svg generieren und ergänzen.

Verifikation

ng build erzeugt ngsw-worker.js + ngsw.json (assetGroups app/assets, Manifest + Icon im Hash-Table). DevTools → Network Offline nach erstem Laden: App startet aus dem SW-Cache, zeigt den gecachten Stand + Offline-Banner. Lighthouse-PWA: installierbar + offline-fähig.

Stufe C: npm run test:fotoqueue (Varianten-Zustandsmaschine + Priorität + Fertig-Kriterium) · npm run test:do-migrations (v0→v17). DevTools Offline: Foto erfassen → Badge „1 warten", Eintrag in IndexedDB taktano-offline-fotos mit drei Blobs; wieder Online → Queue drainet thumb→display→ original (Popover zeigt N/3), Fotos erscheinen (/api/foto), Badge 0; zweiter Drain / Reload = No-op (Idempotenz, kein Duplikat/Orphan). Kachel lädt das Thumbnail, „HQ" das Original.


↩ Zurück zur Doku-Landkarte · Lesepfade · Register (alle OPs)