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)
| Stufe | Inhalt | Status |
|---|---|---|
| B — PWA-Shell + Read-Offline | Service Worker (App-Shell-Cache), Web-Manifest (installierbar), IndexedDB-State-Snapshot, Offline-Banner („nur Ansicht") | ✅ gebaut (PR B) |
| C — Upload-Queue | Bild-Uploads offline puffern (IndexedDB) + bei Reconnect idempotent nachsenden; 3 Varianten (Thumbnail · verkleinert · Original), Thumbnail-priorisiert | ✅ gebaut (PR C + Varianten) |
| Voll-Offline-Write | beliebige Aktionen offline absetzen → Idempotenz-Keys + Reconnect-Replay + Konflikt-Handling gegen die DO-Wahrheit | offen (eigener OP, groß) |
Architektur (Stufe B)
- Service Worker: Angular Service Worker (
@angular/service-worker), Konfigclient/ngsw-config.json(App-Shellprefetch; Fonts/Bilderlazy;navigationUrlsschließt/api/**aus, damit keinindex.html-Fallback für API-Routen). Registrierung inclient/src/app/app.config.tsviaprovideServiceWorker(..., { enabled: !isDevMode(), registrationStrategy: 'registerWhenStable:30000' })→ nur im Produktions-Build aktiv (kein SW-Cache stört die Dev-HMR). Build erzeugtngsw-worker.jsngsw.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_colorliegen außerhalb des No-Hex-Gates (scripts/check-no-hex.shscannt nurclient/src/app+styles.css), daher literale Brand-Hex erlaubt (konsistent zum bestehendenindex.html-theme-color). - Read-Offline-Cache:
StateCacheService(client/src/app/services/state-cache.service.ts) legt den zuletzt empfangenenLeitstandStateals einzelnen Snapshot in IndexedDB ab (kein Verlauf — den trägt das Audit-Log, OP-AUDIT-1).LeitstandServicepersistiert bei jedemstate-Broadcast und hydriert beim Start aus dem Cache, solange noch kein Live-Broadcast kam (Broadcast hat Vorrang). SignalelastStateAt+hydratedFromCache→offlineStalespeist 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 veralteterLeitstandState-Shapes nach Vertrags-Änderungen). Bewusst getrennt vonAPP_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, Storequeue, keyPathfotoId) nach demStateCacheService-Muster (best-effort, Fehler alsbooleangemeldet). Speichert je Eintrag die drei Varianten-Blobs mit eigenenuploaded/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-Selbsttesttest:fotoqueue):naechsterVariantenSchritt(variante, connected)=upload → register → fertigje Variante (wartenwenn 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) + triggertdrainFotoQueue(). 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 (HTTPputFotomitvariante/ WSsendfoto.variante). Trigger: Socket-open(initial- Reconnect) + Window-
online-Event + State-Broadcast (= Registrierungs-Ack, s. u.) + nach jeder Erfassung.
- Reconnect) + Window-
- Fehler-Klassifizierung (kein stiller Verlust, kein Endlos-Retry):
- Persistenz (IndexedDB):
add()/put()/update()liefernboolean. Scheitert das Puffern beim Erfassen (kein IndexedDB / Private-Modus / Quota voll), wirftuploadFoto()→ 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 einupdate()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.
- Persistenz (IndexedDB):
- Registrierung ack-getriggert (kein Orphan bei verpuffter WS-Meldung): die
registered-Flags werden nicht blind nachsend()gesetzt — eine über einen abreißenden Socket verpufftefoto.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üftthumbR2Key/r2Key/originalR2Keyanteilschritte[].fotos[]); der State-Broadcast triggert den nächsten Drain.send()ist idempotent (Server-Handler ist ein Upsert nachid+variante). - Idempotenz server-seitig:
/api/foto/uploadleitet den R2-Key deterministisch aus derfotoIdab, 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-Handlerfoto.variantelegt denFotoAttachmentbeim ersten Eintreffen an (i. d. R. der Thumbnail, da priorisiert) und hängt die weiteren Varianten-Keys an (Upsert nachid). Schema:FotoAttachment.thumbR2Keyadditiv (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 demthumb-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", weilPartySocketbei echtem Offline ohnehin automatisch reconnectet (kein Cache-Nuke ohne Nutzen). SwUpdate-Auto-Update: bei Netz-Rückkehr (online) / Tab-Fokus (visibilitychange)checkForUpdate(); beiVERSION_READYerscheint 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_VERSIONder DO-Persistenz; DTO inserver/party/leitstand.ts, gesetzt insnapshot()). Der Client stempelt jeden Snapshot mit dieserserverSchema(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 inkompatiblemLeitstandState-Shape Alt-Snapshots beimload()— es wird nie ein veralteter Shape hydriert. Sie wird erhöht, sobald das State-Shape ein neues Pflichtfeld bekommt — mit v0.155.0 auf2[schemaVersionwurde 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 dannhartNeuLaden()→ 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: nebennavigator.onLinelä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; undclear()meldet Erfolg/Misserfolg (boolean), bei Misserfolg kein Reload (kein Schein-Resync, ehrlicher Toast). Die persistente Foto-Upload-Queue (taktano-offline-fotos) bleibt unberührt —clear()löscht nur den View-State-Keyleitstand, 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 ausicon.svggenerieren 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)