Merge lane/offline in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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:<clientId>"` → 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-<VERSION>`, alte beim Aktivieren gelöscht, max. 600 Einträge).
|
||||
- Navigationen unter `/m/**` network-first; offline: `/m`, `/m/orders`, `/m/orders/<id>[/…]` → `/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/<id>`, `/files/<id>`) 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`: `<OfflineRuntime />` 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`: `<InstallHint />`, `<LogoutForm>` 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/<id>`) → „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:<id>:<typ>")` 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 ✓).
|
||||
Reference in New Issue
Block a user