From e5d5ccad6f57e1118bcb26f14d2b03dc21a7904a Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 17:26:20 +0200 Subject: [PATCH] L7 Offline & PWA: Lane-Bericht Co-Authored-By: Claude Opus 5 --- docs/craftvia/lanes/offline.md | 98 ++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/craftvia/lanes/offline.md diff --git a/docs/craftvia/lanes/offline.md b/docs/craftvia/lanes/offline.md new file mode 100644 index 0000000..6b0ed20 --- /dev/null +++ b/docs/craftvia/lanes/offline.md @@ -0,0 +1,98 @@ +# Lane L7 – Offline & PWA (`lane/offline`) + +Stand: 2026-09-14 · Basis `d5c1221` (`feature/craftvia-mvp`) + +## 1. Umfang / erfüllte Spec-Punkte + +| Spec | Umsetzung | +|---|---| +| §3.2 PWA | Service Worker `public/sw.js` (statisch, kein Build-Plugin), Manifest `start_url /m`, `scope /`, `display standalone`, Icons 192/512/maskable vorhanden, iOS-Meta (`appleWebApp`, apple-touch-icon) steht bereits im Root-Layout; Installationshinweis im Profil (`beforeinstallprompt`-Button, sonst iOS-/Browser-Anleitung) | +| §22 Offline-Hinweis, Sync-Status | Sync-Badge im Mobile-Header (Zahl offen, Warnsymbol bei Fehler/Konflikt/abgelaufener Anmeldung, Text + Icon); Offline-Hinweis in Offline-Ansicht und Sync-Seite; automatische Zwischenspeicherung des Notizentwurfs in IndexedDB | +| §23.2 Offline verfügbare Daten | Vorab-Download nach Login, alle 5 min und nach jedem erfolgreichen Sync: heute + 3 Tage + laufende Aufträge (Kunde, Objekt, Kontakte, Checkliste, Material, Pflichtfotos, Dokument-Metadaten, Objekt-Historie); Dokumente der Kategorien technische Zeichnung, Grundriss, Schaltplan, Montageanleitung, Sicherheitsunterlage ≤ 25 MB im Dokument-Cache (LRU 300 MB); `navigator.storage.persist()` + Speicheranzeige | +| §23.3 Offline erfassbar | Zeiten (Start/Losfahren/Pause/Weiter/Ende), Status „Annehmen“, Notizen, Checkliste, Material (L4-Formulare), Fotos und Sprachnotizen über die Upload-Warteschlange; Bericht/Unterschrift laufen ebenfalls über `submitOp` (Ops von L5), sobald L5 sie offline nutzt | +| §23.4 Synchronisation | Outbox je Op: lokale ID (`clientOpId`), Server-IDs (idMap), Zeitstempel, Benutzer/Mandant, `baseVersion`, Status queued/sending/applied/conflict/rejected, Versuche, letzter Fehler; Auslöser: online-Event, 30 s, Sichtbarkeitswechsel, „Jetzt synchronisieren“, Background Sync (Bonus) | +| §23.5 Konflikte | nichts überschrieben (Serverprüfung L4), Konflikt lokal gelistet mit Klartext „Auftrag wurde im Büro geändert – Ihre Statusänderung wurde nicht übernommen. Das Büro prüft den Vorgang.“, nicht verwerfbar (nur ausblendbar); unabhängige Ops laufen weiter | +| US-012 | alle Kriterien: Daten offline, lokale Speicherung, Foto-Warteschlange, sichtbarer Status, Übertragung bei Verbindung, keine stille Überschreibung | +| ARCHITEKTUR §4.6 | identischer Pfad online/offline: Outbox → `POST /api/v1/sync` (Batch ≤ 50) → `applyOperations`; Uploads vorher über `POST /api/v1/uploads` | +| `OFFLINE_MAX_DAYS` (Default 7) | älteres Bundle → Hinweis „veraltet“ (Sync-Seite, Offline-Ansicht); alte Ops werden trotzdem gesendet, Server speichert `SyncOperation.clientCreatedAt` (E2E geprüft) | + +### Kernregeln der Outbox (`src/lib/offline/outbox-core.ts`, reine Funktionen) +- Reihenfolge je Auftrag strikt; eine noch ausstehende Op (Backoff, Upload fehlt) blockiert nur Folge-Ops **desselben** Auftrags. +- Endgültige Ergebnisse (applied/conflict/rejected) blockieren nichts. +- Blob-Referenz `documentId: "blob:"` → Upload zuerst → Payload umgeschrieben; Upload endgültig abgelehnt → Op `rejected` („Datei wurde nicht angenommen“). +- idMap wird auf wartende Ops angewendet (eigenes `clientId`-Feld bleibt, Idempotenz). +- Verkettete `baseVersion`: Eine Statusänderung hinter einer eigenen, noch offenen Session-/Status-Op desselben Auftrags übernimmt die `entityVersion` aus deren Server-Ergebnis (sonst würde die eigene Kette immer kollidieren). +- Transiente Fehler (Netz, 5xx, `internal`) → exponentielles Backoff 2 s … 5 min mit Jitter; 401 → Pass stoppt, Hinweis „neu anmelden“, nichts geht verloren. +- „Erneut versuchen“ bei `rejected` erzeugt eine neue `clientOpId` (der Server hat das alte Ergebnis gespeichert). +- Optimistische Ansicht = Server-Snapshot + eigene offene Ops (Snapshot bleibt unverändert → abgelehnte Ops verschwinden automatisch aus der Ansicht). + +### Service Worker (`public/sw.js`) +- `/_next/static/**` cache-first, versionierter Cache (`craftvia-static-`, alte beim Aktivieren gelöscht, max. 600 Einträge). +- Navigationen unter `/m/**` network-first; offline: `/m`, `/m/orders`, `/m/orders/[/…]` → `/m/offline?from=…` (rendert aus IndexedDB), andere Seiten → zuletzt gecachte Seite; `/m/offline` + referenzierte Assets werden bei Installation und nach jedem Shell-Mount vorgeladen. +- Dokumente (`/api/v1/field/documents/`, `/files/`) cache-first nur, wenn vom Vorab-Download im Dokument-Cache abgelegt; LRU-Zeitstempel beim Zugriff. +- Nie behandelt/gecacht: Nicht-GET, `/api/v1/sync`, `/api/v1/uploads`, `/api/v1/field/bundle`, `/api/auth`, `/api/platform-auth`, `/login`, `/logout`, `/select-tenant`, `/platform`, fremde Origins. +- Update-Flow: neue SW-Version wartet → Hinweis „Neue Version verfügbar – neu laden“ → `SKIP_WAITING` → Reload. CSP: `worker-src 'self'` passt, keine Änderung an `next.config.ts` nötig. +- Im Dev-Server nur opt-in (`localStorage.setItem("craftvia.sw","1")`), sonst würden HMR-Chunks gecacht. + +### Datentrennung +IndexedDB `craftvia-offline` (Stores `outbox`, `blobs`, `bundle`, `meta`), jeder Datensatz mit `tenantId`, `userId`, Index `ctxKey = tenantId:userId`; jede Leseoperation filtert danach. Logout (Profil) löscht die Daten des Kontexts sowie Seiten-/Dokument-Cache – bei nicht übertragenen Einträgen vorher Warnung mit „Erst synchronisieren“ / „Trotzdem abmelden“. Beim Start eines anderen Kontexts (Mandantenwechsel, anderer Nutzer) werden Seiten-/Dokument-Cache geleert und fremde Kontexte ohne offene Einträge gelöscht; Kontexte mit offenen Einträgen bleiben getrennt erhalten, bis dieser Nutzer sich wieder anmeldet. + +## 2. Routen / Screens + +| Route | Inhalt | +|---|---| +| `/m/sync` | Verbindung, letzte Synchronisation, letzter Vorab-Download, Veraltet-Hinweis, offene Ops/Uploads mit Größe und Upload-Fortschritt, Warteschlange (Versuche, nächster Versuch), Fehler & Konflikte im Klartext (erneut versuchen, verwerfen mit Bestätigung nur für `rejected`, Konflikthinweis ausblenden), Speicher (`storage.estimate`, persistiert ja/nein, Anzahl Offline-Aufträge), „Für offline speichern“, „Offline-Ansicht öffnen“, „Lokale Daten zurücksetzen“ (Bestätigung + Warnung bei offenen Einträgen) | +| `/m/offline` | Offline-Ansicht aus IndexedDB: Auftragsliste (Status, Zeitfenster, Adresse, „n Änderungen warten“, Konflikt-/Fehlerhinweis) und Auftragsdetail (Zeitaktionen Annehmen/Losfahren/Arbeit starten/Pause/Weiter/Zeiterfassung beenden, Hinweise, Objekt mit Zugang/Parken/Sicherheit/Technik, Kunde + Anrufen, Leistungsumfang, Checkliste, Foto aufnehmen, Pflichtfotos, Notiz mit Entwurf, Materialfortschritt, Dokumente mit „offline verfügbar“, Objekt-Historie) | +| Mobile-Header | Sync-Badge (Link auf `/m/sync`), Update-Hinweis | +| `/m/profile` | Installationshinweis, Abmelden mit Offline-Schutz | + +Browser-Check 375×812 (Dev-Server :3107, `monteur@demo.example`): `/m/sync` und `/m/offline` gerendert, Badge „Synchron“, Sync und Bundle-Pull liefen automatisch; Touch-Ziele ≥ 48 px (Badge 44 px im Header). + +## 3. Dateien + +**Neu (Ownership L7)** +- `public/sw.js` +- `src/lib/offline/`: `types.ts`, `outbox-core.ts`, `bundle-core.ts`, `sync-engine.ts`, `memory-store.ts`, `db.ts` (IndexedDB), `outbox.ts` (Browser-Outbox, Sync-Loop, `submitOp`, `queueBlob`), `prefetch.ts` (Bundle-Pull, Dokument-Cache, Speicher), `read.ts`, `drafts.ts`, `doc-cache.ts`, `ids.ts` +- `src/components/offline/`: `offline-runtime.tsx` (Server-Wrapper: Session-Kontext + `OFFLINE_MAX_DAYS`), `offline-runtime-client.tsx` (SW-Registrierung/Update, Sync-Loop), `sync-badge.tsx`, `sync-panel.tsx`, `offline-view.tsx`, `logout-form.tsx`, `install-hint.tsx`, `hooks.ts` (`useOfflineState`, `useOfflineDraft`), `format.ts` +- `src/app/(field)/m/sync/{layout,page}.tsx` (Platzhalter ersetzt, Modul-Gate `field`), `src/app/(field)/m/offline/{layout,page}.tsx` +- `messages/de/offline.json`, `messages/en/offline.json` +- `scripts/test-offline-core.ts`, `scripts/test-offline-sync-e2e.ts` + +**Eingriffe in L4-Dateien (vom Auftrag vorgesehen, minimal)** +- `src/lib/field/client-ops.ts`: `submitOp` delegiert an die Outbox (Signatur unverändert), `newClientId`/`deviceId` nach `lib/offline/ids.ts` verschoben und re-exportiert, zusätzlich `isQueued`, `queueBlob` exportiert +- `src/app/(field)/m/layout.tsx`: `` im Header (Import + 1 Zeile) +- `src/components/field/photo-capture.tsx`, `voice-recorder.tsx`: `uploadFieldFile` → `queueBlob` (Upload durch die Outbox vor `photo.attach`/`voice.attach`), kein `router.refresh()` bei lokal gespeicherten Einträgen +- `src/components/field/note-form.tsx`: Entwurf in IndexedDB (`useOfflineDraft`) statt localStorage +- `src/app/(field)/m/(core)/profile/page.tsx`: ``, `` statt Formular +- `public/site.webmanifest`: `start_url` `/dashboard` → `/m` + +Keine Schemaänderung, keine Migration, keine neuen npm-Abhängigkeiten, keine Fundament-Dateien geändert, keine Fremd-Einzeiler in `nav.ts`/`processors/index.ts`/Audit-Labels. + +## 4. Tests + +- `scripts/test-offline-core.ts` – **67 Prüfungen**, ohne Infrastruktur: Reihenfolge je Auftrag, Batch ≤ 50 (120 Ops → 3 Batches), Retry/Backoff (Netz, `internal`, 401, endgültige Fehler, „erneut versuchen“ mit neuer `clientOpId`), Konflikt stoppt keine unabhängigen Ops, Duplicate eines Konflikts, Blob-Upload vor Op inkl. Payload-Umschreibung/Freigabe/abgelehntem Upload/Upload-Backoff, idMap, verkettete `baseVersion`, Mandanten-/User-Trennung der lokalen Stores (Outbox, Blobs, Bundle, Entwürfe, `clearContext`, Sync-Pass sendet nur eigene Ops), optimistische Ansicht, Vorab-Auswahl heute + 3 Tage + laufend, Veraltet/`OFFLINE_MAX_DAYS`, Dokumentauswahl ≤ 25 MB, LRU, Service-Worker-Regeln (Konstanten synchron, nur GET, Ausschlüsse, Fallback, Update-Flow). +- `scripts/test-offline-sync-e2e.ts` – **38 Prüfungen** gegen die Lane-DB mit echten Services (`applyOperations`, `storeFieldUpload`, `getFieldBundle`): 20 Ops offline (10 Tage alt) → 1 Upload → **ein Batch → 20× applied**, `clientCreatedAt` protokolliert, Datensätze korrekt; **Wiederholung → 20× duplicate**, keine Doppelanlagen; Konflikt durch Büroänderung stoppt unabhängige Notiz nicht; **Mandantentrennung** (B: kein Auftrag von A im Bundle, Notiz/Statusänderung/Foto-Upload auf A → not_found/abgelehnt, A unverändert); **Rollen/Scope** (Monteur ohne Zuweisung: nicht im Bundle, Ops → not_found endgültig, keine Session angelegt). + +Manueller Browser-Test (Chrome DevTools, Produktions-Build oder Dev mit `craftvia.sw=1`) – Ablauf: als Monteur `/m` öffnen (SW installiert, Bundle geladen) → DevTools › Network › Offline → Auftrag öffnen (Weiterleitung auf `/m/offline?from=/m/orders/`) → „Arbeit starten“ (Status „In Arbeit“, Badge „1 offen“) → Foto aufnehmen („Wartet auf Übertragung“) → Notiz speichern → Online → Badge wird innerhalb weniger Sekunden zu „Synchron“, `/m/sync` „Alles übertragen“, Backoffice zeigt Session/Foto/Notiz. In dieser Lane automatisiert geprüft: Rendering beider Seiten, automatischer Sync + Pull, 105 Logikprüfungen; das DevTools-Offline-Umschalten selbst ist über das Browser-Tool nicht steuerbar und wurde nicht ausgeführt. + +HTTP-Smoke (:3107, Seed-Nutzer): `/m/sync`, `/m/offline`, `/m/profile`, `/m` → 200 mit erwarteten Inhalten; `/sw.js` 200 `application/javascript`; `/site.webmanifest` 200 mit `start_url /m`; `/api/v1/field/bundle` 200 (Monteur, `admin2@` Mandant demo2); anonym `/m/sync` → 307 Login, anonym `POST /api/v1/sync` → 401. + +## 5. Stubs & Abhängigkeiten + +Keine Stubs. Genutzt: L4 `/api/v1/sync`, `/api/v1/uploads`, `/api/v1/field/bundle`, `/api/v1/field/documents/[id]`, `resolveActions`/`StatusBadge`/UI-Klassen aus `components/field`. L5 (Bericht/Unterschrift) kann `useOfflineDraft("report::")` aus `components/offline/hooks.ts` für den Berichtsentwurf nutzen – noch nicht eingebunden (L5-Dateien). + +## 6. Bekannte Lücken / Hinweise an den Architekten + +1. **`src/proxy.ts` (Fundament):** `/sw.js` liegt hinter dem Session-Gate. Registrierung/Update funktionieren nur angemeldet (bei abgelaufener Session schlägt der Update-Check still fehl, der alte SW bleibt). Empfehlung: `sw.js` in die Matcher-Ausnahme aufnehmen; zusätzlich in `next.config.ts` für `/sw.js` `Cache-Control: no-cache` setzen (heute greift Next-Default `max-age=0`, `updateViaCache: "none"` ist gesetzt). +2. **Abschließen offline** ist bewusst gesperrt (Hinweis): Pflichtangaben-Prüfung braucht Serverdaten. Arbeitsende (`session.end`) geht offline. +3. **Material in der Offline-Ansicht** nur als Fortschritt; Erfassung über die L4-Seite (Formulare laufen bereits über die Outbox, aber die Seite selbst ist ein Server Component und offline nur als gecachte Seite erreichbar, falls vorher besucht). +4. **Bundle ohne Notizen/Fotos/Sessions**: Die Offline-Ansicht zeigt nur eigene lokale Einträge; die eigene Session wird aus dem Auftragsstatus abgeleitet (bei Teamaufträgen mit mehreren Monteuren ungenau). Erweiterung von `getFieldBundle` (L4) um `mySession` empfohlen. +5. **Verkettete baseVersion**: Eine Büroänderung zwischen einer eigenen additiven Session-Op und der folgenden Statusänderung desselben Auftrags wird nicht als Konflikt erkannt, weil Session-Ops laut §4.6 konfliktfrei sind. +6. **Mandantenwechsel im Backoffice** (`tenant-switcher.tsx`, Fundament) löscht lokale Daten nicht direkt; das geschieht beim nächsten Öffnen der Mobile-Shell (Kontextwechsel → Caches geleert, fremde Kontexte ohne offene Einträge gelöscht). +7. **Background Sync** nur Chromium; Safari/iOS synchronisieren bei geöffneter App (online-Event, 30 s, Sichtbarkeit). +8. Veraltete Keys `field.sync.immediate/offlineHint/status` in `messages/*/field.json` (L4) werden nicht mehr genutzt. +9. Wurde ein Foto erfasst, aber nie angehängt (Formular verworfen), wird der lokale Blob nach 24 h gelöscht. + +## 7. Gate + +`npm run gate` grün: prisma generate, tsc, lint (0 Fehler, 2 bestehende Warnungen außerhalb L7), build inkl. Modul-Guard-Check (27 Action-Dateien), **44/44 Testskripte** (davon neu `test-offline-core.ts` 67 ✓, `test-offline-sync-e2e.ts` 38 ✓).