Merge lane/offline in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 17:28:04 +02:00
co-authored by Claude Opus 5
38 changed files with 4222 additions and 120 deletions
+98
View File
@@ -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 ✓).
+171
View File
@@ -0,0 +1,171 @@
{
"badge": {
"label": "Synchronisation öffnen",
"synced": "Synchron",
"pending": "{count} offen",
"problem": "Prüfen",
"syncing": "Sync …"
},
"update": {
"available": "Neue Version verfügbar – neu laden",
"reload": "Neu laden"
},
"sync": {
"title": "Synchronisation",
"connection": "Verbindung",
"offlineHint": "Eingaben werden auf diesem Gerät gespeichert und übertragen, sobald wieder Verbindung besteht.",
"lastSync": "Letzte Synchronisation",
"lastPull": "Aufträge offline gespeichert",
"never": "Noch nie",
"stale": "Die Offline-Daten sind älter als {days} Tage. Bitte mit Verbindung aktualisieren.",
"status": "Stand",
"pendingOps": "{count, plural, =0 {Keine Änderungen offen} one {# Änderung wartet auf Übertragung} other {# Änderungen warten auf Übertragung}}",
"pendingUploads": "{count, plural, =0 {Keine Dateien offen} one {# Datei wartet auf Upload} other {# Dateien warten auf Upload}}",
"uploadSize": "{size} zu übertragen",
"uploading": "Datei wird hochgeladen … {percent} %",
"syncing": "Wird synchronisiert …",
"syncNow": "Jetzt synchronisieren",
"allSynced": "Alles übertragen.",
"waitingForAuth": "Anmeldung abgelaufen. Bitte neu anmelden – Ihre Eingaben bleiben auf dem Gerät gespeichert.",
"login": "Neu anmelden",
"problems": "Fehler und Konflikte",
"noProblems": "Keine Fehler oder Konflikte.",
"queue": "Warteschlange",
"attempts": "{count, plural, one {# Versuch} other {# Versuche}}",
"nextAttempt": "Nächster Versuch um {time}",
"createdAt": "Erfasst {time}",
"retry": "Erneut versuchen",
"discard": "Eintrag verwerfen",
"discardConfirm": "Eintrag endgültig verwerfen? Die Eingabe geht verloren.",
"discardYes": "Endgültig verwerfen",
"cancel": "Abbrechen",
"hide": "Hinweis ausblenden",
"storage": "Speicher auf diesem Gerät",
"storageUsage": "{usage} von {quota} belegt",
"storagePersisted": "Offline-Daten sind dauerhaft gespeichert.",
"storageNotPersisted": "Der Browser darf Offline-Daten bei Speichermangel löschen.",
"storageUnknown": "Speicherverbrauch nicht verfügbar.",
"offlineOrders": "{count, plural, =0 {Keine Aufträge offline verfügbar} one {# Auftrag offline verfügbar} other {# Aufträge offline verfügbar}}",
"saveOffline": "Für offline speichern",
"saveOfflineHint": "Heutige, kommende 3 Tage und laufende Aufträge inklusive Zeichnungen, Montageanleitungen und Sicherheitsunterlagen.",
"openOffline": "Offline-Ansicht öffnen",
"reset": "Lokale Daten zurücksetzen",
"resetConfirm": "Alle Offline-Daten Ihres Kontos auf diesem Gerät löschen?",
"resetWarning": "{count, plural, one {# Eingabe wurde} other {# Eingaben wurden}} noch nicht übertragen und {count, plural, one {geht} other {gehen}} verloren.",
"resetYes": "Jetzt zurücksetzen",
"resetDone": "Lokale Daten gelöscht."
},
"op": {
"session_start": "Einsatz gestartet",
"session_pause": "Pause",
"session_resume": "Weiter",
"session_end": "Zeiterfassung beendet",
"work_order_transition": "Statusänderung",
"note_create": "Notiz",
"checklist_toggle": "Checkliste",
"material_upsert": "Material",
"photo_attach": "Foto",
"voice_attach": "Sprachnotiz",
"report_save_draft": "Berichtsentwurf",
"report_submit": "Bericht",
"signature_capture": "Unterschrift",
"emergency_create": "Notdiensteinsatz"
},
"state": {
"queued": "Wartet",
"sending": "Wird gesendet",
"applied": "Übertragen",
"conflict": "Konflikt",
"rejected": "Nicht übernommen"
},
"problem": {
"conflictTransition": "Auftrag wurde im Büro geändert – Ihre Statusänderung wurde nicht übernommen. Das Büro prüft den Vorgang.",
"conflictReport": "Auftrag wurde im Büro geändert – Ihr Bericht wurde nicht übernommen. Das Büro prüft den Vorgang.",
"conflict": "Auftrag wurde im Büro geändert – Ihre Änderung wurde nicht übernommen. Das Büro prüft den Vorgang.",
"notFound": "Der Auftrag ist nicht mehr für Sie freigegeben. Die Eingabe wurde nicht übernommen.",
"forbidden": "Für diese Aktion fehlt Ihnen die Berechtigung.",
"blocked": "Pflichtangaben fehlen noch – bitte am Auftrag ergänzen und erneut versuchen.",
"invalid": "Die Eingabe ist unvollständig oder nicht mehr gültig.",
"upload": "Die Datei wurde nicht angenommen (Format oder Größe).",
"unauthorized": "Anmeldung abgelaufen – bitte neu anmelden.",
"network": "Keine Verbindung – wird automatisch erneut versucht.",
"internal": "Technischer Fehler – wird automatisch erneut versucht."
},
"view": {
"title": "Offline-Ansicht",
"offlineHint": "Keine Verbindung. Sie sehen die auf diesem Gerät gespeicherten Aufträge.",
"onlineHint": "Verbindung besteht wieder.",
"backOnline": "Zur normalen Ansicht",
"empty": "Keine Aufträge auf diesem Gerät gespeichert. Mit Verbindung werden heutige und kommende Aufträge automatisch gespeichert.",
"loading": "Offline-Daten werden geladen …",
"notReady": "Offline-Daten sind erst nach einer Anmeldung mit Verbindung verfügbar.",
"notFound": "Dieser Auftrag ist nicht auf dem Gerät gespeichert.",
"backToList": "Alle gespeicherten Aufträge",
"open": "Auftrag öffnen",
"count": "{count, plural, one {# Auftrag} other {# Aufträge}}",
"pending": "{count, plural, one {# Änderung wartet} other {# Änderungen warten}}",
"conflict": "Konflikt – siehe Synchronisation",
"rejected": "Nicht übernommen – siehe Synchronisation",
"savedAt": "Stand: {time}",
"noDate": "Ohne Termin",
"customer": "Kunde",
"site": "Objekt",
"contact": "Ansprechpartner",
"call": "Anrufen",
"access": "Zugang",
"parking": "Parken",
"safety": "Sicherheit",
"technical": "Technik",
"hints": "Hinweise für Monteure",
"scope": "Leistungsumfang",
"description": "Beschreibung",
"checklist": "Checkliste",
"checklistEmpty": "Keine Checkliste.",
"required": "Pflicht",
"done": "Erledigt",
"open_item": "Offen",
"materials": "Material",
"materialsProgress": "{done} von {total} Positionen erfasst",
"documents": "Dokumente",
"noDocuments": "Keine Dokumente.",
"docOffline": "Offline verfügbar",
"docOnline": "Nur mit Verbindung",
"history": "Objekt-Historie",
"noHistory": "Keine freigegebenen Berichte am Objekt.",
"notes": "Notizen",
"notePlaceholder": "Kurz beschreiben, was vor Ort passiert ist …",
"noteSave": "Notiz speichern",
"draftRestored": "Entwurf wiederhergestellt.",
"photos": "Fotos",
"photo": "Foto aufnehmen",
"photoRequirements": "Pflichtfotos",
"photoMissing": "Fehlt",
"photoDone": "Vorhanden",
"pendingBadge": "Wartet auf Übertragung",
"time": "Zeiterfassung",
"actionAccept": "Annehmen",
"actionTravel": "Losfahren",
"actionStart": "Arbeit starten",
"actionPause": "Pause",
"actionResume": "Weiter",
"actionEnd": "Zeiterfassung beenden",
"completeOnline": "Abschließen ist nur mit Verbindung möglich (Pflichtangaben werden geprüft).",
"saving": "Wird gespeichert …",
"queued": "Gespeichert – wird übertragen, sobald Verbindung besteht.",
"saved": "Gespeichert und übertragen.",
"error": "Konnte nicht gespeichert werden."
},
"logout": {
"warning": "{count, plural, one {# Eingabe wurde} other {# Eingaben wurden}} noch nicht übertragen. Beim Abmelden werden die Offline-Daten dieses Geräts gelöscht – {count, plural, one {die Eingabe geht} other {die Eingaben gehen}} verloren.",
"syncFirst": "Erst synchronisieren",
"confirm": "Trotzdem abmelden",
"cancel": "Abbrechen"
},
"install": {
"title": "App auf dem Gerät",
"installed": "Craftvia ist als App installiert.",
"button": "Auf dem Startbildschirm installieren",
"ios": "Auf iPhone oder iPad: unten das Teilen-Symbol antippen und „Zum Home-Bildschirm“ wählen.",
"other": "Im Browser-Menü „App installieren“ oder „Zum Startbildschirm hinzufügen“ wählen."
}
}
+171
View File
@@ -0,0 +1,171 @@
{
"badge": {
"label": "Open synchronisation",
"synced": "Synced",
"pending": "{count} open",
"problem": "Check",
"syncing": "Sync …"
},
"update": {
"available": "New version available – reload",
"reload": "Reload"
},
"sync": {
"title": "Synchronisation",
"connection": "Connection",
"offlineHint": "Entries are stored on this device and sent as soon as a connection is available.",
"lastSync": "Last synchronisation",
"lastPull": "Orders saved for offline use",
"never": "Never",
"stale": "Offline data is older than {days} days. Please update with a connection.",
"status": "As of",
"pendingOps": "{count, plural, =0 {No pending changes} one {# change waiting to be sent} other {# changes waiting to be sent}}",
"pendingUploads": "{count, plural, =0 {No pending files} one {# file waiting for upload} other {# files waiting for upload}}",
"uploadSize": "{size} to transfer",
"uploading": "Uploading file … {percent} %",
"syncing": "Synchronising …",
"syncNow": "Synchronise now",
"allSynced": "Everything sent.",
"waitingForAuth": "Your session expired. Please sign in again – your entries stay on this device.",
"login": "Sign in again",
"problems": "Errors and conflicts",
"noProblems": "No errors or conflicts.",
"queue": "Queue",
"attempts": "{count, plural, one {# attempt} other {# attempts}}",
"nextAttempt": "Next attempt at {time}",
"createdAt": "Recorded {time}",
"retry": "Try again",
"discard": "Discard entry",
"discardConfirm": "Discard this entry permanently? The input will be lost.",
"discardYes": "Discard permanently",
"cancel": "Cancel",
"hide": "Hide notice",
"storage": "Storage on this device",
"storageUsage": "{usage} of {quota} used",
"storagePersisted": "Offline data is stored persistently.",
"storageNotPersisted": "The browser may delete offline data when storage runs low.",
"storageUnknown": "Storage usage not available.",
"offlineOrders": "{count, plural, =0 {No orders available offline} one {# order available offline} other {# orders available offline}}",
"saveOffline": "Save for offline use",
"saveOfflineHint": "Today's, the next 3 days' and running orders including drawings, assembly instructions and safety documents.",
"openOffline": "Open offline view",
"reset": "Reset local data",
"resetConfirm": "Delete all offline data of your account on this device?",
"resetWarning": "{count, plural, one {# entry has} other {# entries have}} not been sent yet and will be lost.",
"resetYes": "Reset now",
"resetDone": "Local data deleted."
},
"op": {
"session_start": "Job started",
"session_pause": "Break",
"session_resume": "Resume",
"session_end": "Time tracking ended",
"work_order_transition": "Status change",
"note_create": "Note",
"checklist_toggle": "Checklist",
"material_upsert": "Material",
"photo_attach": "Photo",
"voice_attach": "Voice note",
"report_save_draft": "Report draft",
"report_submit": "Report",
"signature_capture": "Signature",
"emergency_create": "Emergency job"
},
"state": {
"queued": "Waiting",
"sending": "Sending",
"applied": "Sent",
"conflict": "Conflict",
"rejected": "Not accepted"
},
"problem": {
"conflictTransition": "The order was changed in the office – your status change was not applied. The office will review it.",
"conflictReport": "The order was changed in the office – your report was not applied. The office will review it.",
"conflict": "The order was changed in the office – your change was not applied. The office will review it.",
"notFound": "The order is no longer assigned to you. The entry was not applied.",
"forbidden": "You are not allowed to perform this action.",
"blocked": "Required information is still missing – please complete it on the order and try again.",
"invalid": "The entry is incomplete or no longer valid.",
"upload": "The file was not accepted (format or size).",
"unauthorized": "Your session expired – please sign in again.",
"network": "No connection – will retry automatically.",
"internal": "Technical error – will retry automatically."
},
"view": {
"title": "Offline view",
"offlineHint": "No connection. You are viewing the orders stored on this device.",
"onlineHint": "Connection is back.",
"backOnline": "Back to the regular view",
"empty": "No orders stored on this device. With a connection, today's and upcoming orders are saved automatically.",
"loading": "Loading offline data …",
"notReady": "Offline data is available after signing in with a connection.",
"notFound": "This order is not stored on this device.",
"backToList": "All stored orders",
"open": "Open order",
"count": "{count, plural, one {# order} other {# orders}}",
"pending": "{count, plural, one {# change waiting} other {# changes waiting}}",
"conflict": "Conflict – see synchronisation",
"rejected": "Not accepted – see synchronisation",
"savedAt": "As of: {time}",
"noDate": "No date",
"customer": "Customer",
"site": "Site",
"contact": "Contact",
"call": "Call",
"access": "Access",
"parking": "Parking",
"safety": "Safety",
"technical": "Technical",
"hints": "Notes for technicians",
"scope": "Scope of work",
"description": "Description",
"checklist": "Checklist",
"checklistEmpty": "No checklist.",
"required": "Required",
"done": "Done",
"open_item": "Open",
"materials": "Material",
"materialsProgress": "{done} of {total} items recorded",
"documents": "Documents",
"noDocuments": "No documents.",
"docOffline": "Available offline",
"docOnline": "Online only",
"history": "Site history",
"noHistory": "No approved reports at this site.",
"notes": "Notes",
"notePlaceholder": "Briefly describe what happened on site …",
"noteSave": "Save note",
"draftRestored": "Draft restored.",
"photos": "Photos",
"photo": "Take photo",
"photoRequirements": "Required photos",
"photoMissing": "Missing",
"photoDone": "Present",
"pendingBadge": "Waiting to be sent",
"time": "Time tracking",
"actionAccept": "Accept",
"actionTravel": "Start travel",
"actionStart": "Start work",
"actionPause": "Break",
"actionResume": "Resume",
"actionEnd": "End time tracking",
"completeOnline": "Completing requires a connection (required information is checked).",
"saving": "Saving …",
"queued": "Saved – will be sent as soon as a connection is available.",
"saved": "Saved and sent.",
"error": "Could not be saved."
},
"logout": {
"warning": "{count, plural, one {# entry has} other {# entries have}} not been sent yet. Signing out deletes the offline data on this device – {count, plural, one {it} other {they}} will be lost.",
"syncFirst": "Synchronise first",
"confirm": "Sign out anyway",
"cancel": "Cancel"
},
"install": {
"title": "App on this device",
"installed": "Craftvia is installed as an app.",
"button": "Install on home screen",
"ios": "On iPhone or iPad: tap the share icon and choose “Add to Home Screen”.",
"other": "Choose “Install app” or “Add to home screen” in the browser menu."
}
}
+1 -1
View File
@@ -3,7 +3,7 @@
"short_name": "Craftvia", "short_name": "Craftvia",
"description": "Handwerk. Digital auf Kurs.", "description": "Handwerk. Digital auf Kurs.",
"lang": "de", "lang": "de",
"start_url": "/dashboard", "start_url": "/m",
"scope": "/", "scope": "/",
"theme_color": "#082E5B", "theme_color": "#082E5B",
"background_color": "#F3F5F7", "background_color": "#F3F5F7",
+180
View File
@@ -0,0 +1,180 @@
/*
* Craftvia service worker (lane L7 Offline & PWA, Spec §23, ARCHITEKTUR §4.6).
* Static file on purpose (no build plugin, Next.js 16). Registered by
* src/components/offline/offline-runtime-client.tsx with scope "/".
*
* Strategies
* /_next/static/** cache-first, versioned cache (hashed file names)
* navigations under /m/** network-first; offline → cached page, order views → /m/offline
* documents of the offline bundle cache-first from DOC_CACHE (filled by src/lib/offline/prefetch.ts,
* /api/v1/field/documents/<id> LRU limit 300 MB); documents not in the cache go to the network
* /files/<id>
* everything else not handled (browser default) — NEVER API mutations, auth
* routes, /api/v1/sync or uploads
*
* Constants mirrored in src/lib/offline/doc-cache.ts (checked by scripts/test-offline-core.ts).
* Bump VERSION when this file changes in a way that needs fresh static assets.
*/
const VERSION = "2026-09-14.1";
const STATIC_CACHE = "craftvia-static-" + VERSION;
const STATIC_PREFIX = "craftvia-static-";
const PAGE_CACHE = "craftvia-pages-v1";
const DOC_CACHE = "craftvia-docs-v1";
const DOC_CACHE_MAX_BYTES = 300 * 1024 * 1024;
const STATIC_MAX_ENTRIES = 600;
const OFFLINE_URL = "/m/offline";
const SYNC_TAG = "craftvia-outbox";
/** Never served from or written to a cache. */
const NEVER_CACHE = ["/api/v1/sync", "/api/v1/uploads", "/api/v1/field/bundle", "/api/auth", "/api/platform-auth", "/login", "/logout", "/select-tenant", "/platform"];
const DOC_PATTERNS = [/^\/api\/v1\/field\/documents\/[^/]+$/, /^\/files\/[^/]+$/];
/** Order list/detail pages: offline they are rendered from the local bundle (/m/offline). */
const BUNDLE_VIEW = /^\/m(\/orders(\/[^/]+(\/[^/]+)?)?)?\/?$/;
const STATIC_REF = /\/_next\/static\/[^"'\s)\\]+/g;
const sw = self;
const matchesPath = (path, prefix) => path === prefix || path.startsWith(prefix + "/");
sw.addEventListener("install", (event) => {
event.waitUntil(
(async () => {
await precacheOffline();
// First install: take over right away. Updates wait for the user ("Neue Version verfügbar").
if (!sw.registration.active) await sw.skipWaiting();
})(),
);
});
sw.addEventListener("activate", (event) => {
event.waitUntil(
(async () => {
const names = await caches.keys();
await Promise.all(names.filter((n) => n.startsWith(STATIC_PREFIX) && n !== STATIC_CACHE).map((n) => caches.delete(n)));
await sw.clients.claim();
})(),
);
});
sw.addEventListener("message", (event) => {
const type = event.data && event.data.type;
if (type === "SKIP_WAITING") event.waitUntil(sw.skipWaiting());
else if (type === "PRECACHE_OFFLINE") event.waitUntil(precacheOffline());
else if (type === "CLEAR_USER_CACHES") event.waitUntil(Promise.all([caches.delete(PAGE_CACHE), caches.delete(DOC_CACHE)]));
});
// Background Sync (bonus, not available in Safari): wake open tabs to flush the outbox.
sw.addEventListener("sync", (event) => {
if (event.tag !== SYNC_TAG) return;
event.waitUntil(
sw.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => list.forEach((c) => c.postMessage({ type: "craftvia:sync" }))),
);
});
sw.addEventListener("fetch", (event) => {
const request = event.request;
if (request.method !== "GET") return;
const url = new URL(request.url);
if (url.origin !== sw.location.origin) return;
const path = url.pathname;
if (NEVER_CACHE.some((p) => matchesPath(path, p))) return;
if (path.startsWith("/_next/static/")) {
event.respondWith(staticCacheFirst(request));
return;
}
if (!url.search && DOC_PATTERNS.some((re) => re.test(path))) {
event.respondWith(documentCacheFirst(event, request, url));
return;
}
if (request.mode === "navigate" && matchesPath(path, "/m")) {
event.respondWith(pageNetworkFirst(request, url));
}
});
async function staticCacheFirst(request) {
const cache = await caches.open(STATIC_CACHE);
const hit = await cache.match(request);
if (hit) return hit;
const res = await fetch(request);
if (res.ok && res.type === "basic") {
await cache.put(request, res.clone());
trimStatic(cache);
}
return res;
}
async function trimStatic(cache) {
const keys = await cache.keys();
const excess = keys.length - STATIC_MAX_ENTRIES;
for (let i = 0; i < excess; i++) await cache.delete(keys[i]);
}
async function documentCacheFirst(event, request, url) {
const cache = await caches.open(DOC_CACHE);
const hit = await cache.match(url.pathname);
if (!hit) return fetch(request);
// LRU bookkeeping: refresh the last-used stamp at most once per hour
const used = Number(hit.headers.get("x-craftvia-used") || 0);
if (Date.now() - used > 60 * 60 * 1000) {
event.waitUntil(
(async () => {
const copy = hit.clone();
const headers = new Headers(copy.headers);
headers.set("x-craftvia-used", String(Date.now()));
await cache.put(url.pathname, new Response(await copy.blob(), { status: 200, headers }));
})().catch(() => undefined),
);
}
return hit;
}
async function pageNetworkFirst(request, url) {
const path = url.pathname;
try {
const res = await fetch(request);
if (res.status >= 500) throw new Error("server unavailable");
const html = (res.headers.get("content-type") || "").includes("text/html");
if (res.ok && res.type === "basic" && !res.redirected && html) {
const cache = await caches.open(PAGE_CACHE);
await cache.put(path === OFFLINE_URL ? OFFLINE_URL : request, res.clone());
}
return res;
} catch {
const cache = await caches.open(PAGE_CACHE);
const offline = await cache.match(OFFLINE_URL, { ignoreSearch: true, ignoreVary: true });
if (path === OFFLINE_URL) return offline || Response.error();
if (BUNDLE_VIEW.test(path) && offline) {
return Response.redirect(OFFLINE_URL + "?from=" + encodeURIComponent(path + url.search), 302);
}
const cached = await cache.match(request, { ignoreVary: true });
if (cached) return cached;
if (offline) return Response.redirect(OFFLINE_URL + "?from=" + encodeURIComponent(path + url.search), 302);
return Response.error();
}
}
/** Caches the offline fallback page and the static assets it references (runs with the user's session cookie). */
async function precacheOffline() {
try {
const res = await fetch(OFFLINE_URL, { credentials: "include", cache: "no-store" });
if (!res.ok || res.redirected) return;
const html = await res.clone().text();
await (await caches.open(PAGE_CACHE)).put(OFFLINE_URL, res);
const assets = Array.from(new Set(html.match(STATIC_REF) || []));
const cache = await caches.open(STATIC_CACHE);
await Promise.all(
assets.concat(["/site.webmanifest"]).map(async (asset) => {
if (await cache.match(asset)) return;
const r = await fetch(asset).catch(() => null);
if (r && r.ok) await cache.put(asset, r);
}),
);
} catch {
// not signed in or offline — retried via PRECACHE_OFFLINE after the next login
}
}
// referenced for documentation/tests: the doc cache limit is enforced by the client (prefetch.ts)
void DOC_CACHE_MAX_BYTES;
+379
View File
@@ -0,0 +1,379 @@
// Lane L7 „Offline & PWA" — reine Outbox-/Bundle-Logik ohne IndexedDB und ohne Server:
// Reihenfolge je Auftrag, Retry/Backoff, idMap-Anwendung, Blob-Upload vor der Op, Konflikt stoppt
// keine unabhängigen Ops, verkettete baseVersion, Mandanten-/User-Trennung der lokalen Stores,
// optimistische Auftragsansicht, Vorab-Download-Auswahl, Dokument-Cache (LRU), Service-Worker-Regeln.
//
// Lauf: npx tsx scripts/test-offline-core.ts (keine Infrastruktur nötig)
import { readFileSync } from "node:fs";
import { randomUUID } from "node:crypto";
import type { SyncOperationInput, SyncOpResult } from "../src/lib/sync/envelope";
import { createMemoryStore } from "../src/lib/offline/memory-store";
import {
applyIdMapToPayload,
applyResults,
backoffMs,
BACKOFF_MAX_MS,
blobRef,
canDiscard,
createEntry,
MAX_BATCH,
problemKey,
prunable,
retryEntry,
selectBatch,
summarize,
} from "../src/lib/offline/outbox-core";
import { enqueueBlob, enqueueOp, runSyncPass, type Transport } from "../src/lib/offline/sync-engine";
import { buildOrderView, isBundleStale, parseMaxDays, selectOfflineOrders, toBundleRecords } from "../src/lib/offline/bundle-core";
import { DOC_CACHE, DOC_CACHE_MAX_BYTES, PAGE_CACHE, selectEvictions, selectOfflineDocuments } from "../src/lib/offline/doc-cache";
import { ctxKeyOf, type BundleOrderData, type OutboxEntry } from "../src/lib/offline/types";
let failures = 0;
const ok = (cond: boolean, msg: string) => {
console.log(`${cond ? "✓" : "✗ FEHLER"} ${msg}`);
if (!cond) failures++;
};
const A = { tenantId: "tenant-a", userId: "user-1" };
const A2 = { tenantId: "tenant-a", userId: "user-2" };
const B = { tenantId: "tenant-b", userId: "user-1" };
const T0 = new Date("2026-09-14T08:00:00.000Z");
const at = (ms: number) => new Date(T0.getTime() + ms);
const fixedRandom = () => 1; // backoff = full step
/** Fake server: records every batch, answers per op via `decide`. */
function fakeTransport(decide: (op: SyncOperationInput) => SyncOpResult, opts: { failBatches?: number; uploadError?: "network" | "invalid" } = {}) {
const batches: SyncOperationInput[][] = [];
const uploads: string[] = [];
const log: string[] = [];
let failBatches = opts.failBatches ?? 0;
const transport: Transport = {
async sendBatch(_device, operations) {
if (failBatches > 0) {
failBatches--;
log.push("batch:network");
return { ok: false, error: "network" };
}
batches.push(operations);
log.push(...operations.map((o) => `op:${o.opType}`));
return { ok: true, response: { results: operations.map(decide), serverTime: new Date().toISOString() } };
},
async upload(blob) {
log.push(`upload:${blob.clientId}`);
if (opts.uploadError) return { ok: false, error: opts.uploadError };
uploads.push(blob.clientId);
return { ok: true, documentId: `doc-${blob.clientId.slice(0, 8)}` };
},
};
return { transport, batches, uploads, log };
}
const applied = (op: SyncOperationInput, extra: Partial<SyncOpResult> = {}): SyncOpResult => ({ clientOpId: op.clientOpId, status: "applied", ...extra });
function order(id: string, extra: Partial<BundleOrderData> = {}): BundleOrderData {
return {
id,
number: `A-${id}`,
title: `Auftrag ${id}`,
status: "assigned",
statusGroup: "planned",
priority: "normal",
isEmergency: false,
plannedStart: null,
plannedEnd: null,
version: 3,
customer: { companyName: "Kunde", firstName: null, lastName: null, street: null, houseNumber: null, postalCode: null, city: null },
checklistItems: [{ id: "chk-1", label: "Absichern", required: true, requiresPhoto: false, checked: false, checkedAt: null, comment: null }],
photoRequirements: [{ id: "req-1", key: "typ", label: "Typenschild", _count: { photos: 0 } }],
materialPlans: [],
materialUsages: [],
documents: [],
siteHistory: [],
...extra,
};
}
async function main() {
console.log("\n— Reihenfolge je Auftrag / Batch —");
{
const store = createMemoryStore();
const ids: string[] = [];
for (let i = 0; i < 3; i++) {
const e = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", clientId: randomUUID(), kind: "general", text: `n${i}` } }, randomUUID(), at(i));
ids.push(e.clientOpId);
}
await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-2", clientId: randomUUID(), kind: "general", text: "x" } }, randomUUID(), at(10));
const fake = fakeTransport((op) => applied(op));
const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(100) });
const sentIds = fake.batches.flat().map((o) => o.clientOpId);
ok(pass.applied === 4 && fake.batches.length === 1, "4 Ops in einem Batch übertragen und angewendet");
ok(JSON.stringify(sentIds.slice(0, 3)) === JSON.stringify(ids), "Ops eines Auftrags in Erfassungsreihenfolge gesendet");
ok((await store.listOps(ctxKeyOf(A))).every((o) => o.status === "applied" && !!o.appliedAt), "lokaler Status applied mit Zeitstempel");
}
{
const ops: OutboxEntry[] = [];
for (let i = 0; i < 120; i++) ops.push(createEntry(A, { opType: "note.create", payload: { workOrderId: `wo-${i % 7}`, kind: "general", text: "t" } }, { clientOpId: randomUUID(), now: at(i), existing: [], seq: i }));
const b = selectBatch(ops, [], at(1000));
ok(b.entries.length === MAX_BATCH && b.operations.length === MAX_BATCH, `Batch auf ${MAX_BATCH} Ops begrenzt`);
const store = createMemoryStore();
for (const o of ops) await store.putOp(o);
const fake = fakeTransport((op) => applied(op));
const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(2000) });
ok(pass.applied === 120 && fake.batches.length === 3 && fake.batches.every((x) => x.length <= MAX_BATCH), "120 Ops → 3 Batches (50/50/20)");
}
console.log("\n— Retry / Backoff —");
{
ok(backoffMs(1, fixedRandom) === 2000 && backoffMs(2, fixedRandom) === 4000 && backoffMs(4, fixedRandom) === 16000, "Backoff verdoppelt sich (2 s, 4 s, 16 s)");
ok(backoffMs(30, fixedRandom) === BACKOFF_MAX_MS, "Backoff gedeckelt auf 5 min");
ok(backoffMs(3, () => 0) === 4000, "Jitter halbiert höchstens");
const store = createMemoryStore();
const e1 = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0));
const e2 = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "b" } }, randomUUID(), at(1));
const fake = fakeTransport((op) => applied(op), { failBatches: 1 });
const p1 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
let ops = await store.listOps(ctxKeyOf(A));
ok(p1.stopped === "network" && ops.every((o) => o.status === "queued" && o.attempts === 1 && o.lastError?.code === "network"), "Netzfehler → zurück in die Warteschlange, Versuch gezählt, Fehler gespeichert");
ok(ops.every((o) => o.nextAttemptAt === at(10 + 2000).toISOString()), "nächster Versuch nach Backoff terminiert");
const p2 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(500), random: fixedRandom });
ok(p2.sent === 0 && fake.batches.length === 0, "während Backoff wird nicht gesendet");
const p3 = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(3000), random: fixedRandom });
ops = await store.listOps(ctxKeyOf(A));
ok(p3.applied === 2 && ops.every((o) => o.status === "applied"), "nach Backoff erfolgreich übertragen");
ok(fake.batches[0].map((o) => o.clientOpId).join() === [e1.clientOpId, e2.clientOpId].join(), "gleiche clientOpIds beim Wiederholen (Idempotenz)");
// server-side transient error per op
const store2 = createMemoryStore();
await enqueueOp(store2, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0));
const fake2 = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "rejected", errorCode: "internal" }));
await runSyncPass({ store: store2, ctx: A, transport: fake2.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
const [t] = await store2.listOps(ctxKeyOf(A));
ok(t.status === "queued" && t.attempts === 1 && !!t.nextAttemptAt, "rejected internal → transient, erneuter Versuch geplant");
const store3 = createMemoryStore();
await enqueueOp(store3, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0));
const fake3 = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "rejected", errorCode: "not_found" }));
await runSyncPass({ store: store3, ctx: A, transport: fake3.transport, deviceId: "d", now: () => at(10) });
const [r] = await store3.listOps(ctxKeyOf(A));
ok(r.status === "rejected" && r.lastError?.code === "not_found" && canDiscard(r) && problemKey(r) === "notFound", "rejected not_found → endgültig, verwerfbar, Klartext-Schlüssel");
const retried = retryEntry(r, at(20), "new-op-id");
ok(retried?.status === "queued" && retried.clientOpId === "new-op-id" && retried.attempts === 0, "erneut versuchen: neue clientOpId (Server hat altes Ergebnis gespeichert)");
const unauthorized = fakeTransport((op) => applied(op));
unauthorized.transport.sendBatch = async () => ({ ok: false, error: "unauthorized" });
const store4 = createMemoryStore();
await enqueueOp(store4, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, randomUUID(), at(0));
const p4 = await runSyncPass({ store: store4, ctx: A, transport: unauthorized.transport, deviceId: "d", now: () => at(10) });
ok(p4.stopped === "unauthorized" && summarize(await store4.listOps(ctxKeyOf(A)), []).waitingForAuth, "401 → Pass stoppt, Hinweis „neu anmelden“");
}
console.log("\n— Konflikt stoppt keine unabhängigen Ops —");
{
const store = createMemoryStore();
const tr = await enqueueOp(store, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "accepted" } }, randomUUID(), at(0));
const sameOrderNote = await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "danach" } }, randomUUID(), at(1));
const other = await enqueueOp(store, A, { opType: "checklist.toggle", payload: { workOrderId: "wo-2", itemId: "c", checked: true } }, randomUUID(), at(2));
const fake = fakeTransport((op) => (op.opType === "work_order.transition" ? { clientOpId: op.clientOpId, status: "conflict", errorCode: "conflict", entityVersion: 5 } : applied(op)));
const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) });
const byId = new Map((await store.listOps(ctxKeyOf(A))).map((o) => [o.clientOpId, o]));
ok(pass.conflicts === 1 && byId.get(tr.clientOpId)?.status === "conflict", "Statusänderung → conflict (nicht überschrieben)");
ok(byId.get(sameOrderNote.clientOpId)?.status === "applied" && byId.get(other.clientOpId)?.status === "applied", "additive Op desselben Auftrags und Op eines anderen Auftrags trotzdem angewendet");
ok(problemKey(byId.get(tr.clientOpId)!) === "conflictTransition" && !canDiscard(byId.get(tr.clientOpId)!), "Konflikt: Klartext „im Büro geändert“, nicht verwerfbar");
ok(summarize([...byId.values()], []).conflicts === 1, "Konflikt in der Zusammenfassung gezählt");
// duplicate of a stored conflict stays a conflict
const dupStore = createMemoryStore();
await enqueueOp(dupStore, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "accepted" } }, randomUUID(), at(0));
const dup = fakeTransport((op) => ({ clientOpId: op.clientOpId, status: "duplicate", errorCode: "conflict", message: "original status: conflict" }));
await runSyncPass({ store: dupStore, ctx: A, transport: dup.transport, deviceId: "d", now: () => at(10) });
ok((await dupStore.listOps(ctxKeyOf(A)))[0].status === "conflict", "duplicate eines gespeicherten Konflikts bleibt conflict");
}
{
// pending op (backoff) blocks only later ops of the same order
const e1 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "a" } }, { clientOpId: "a1", now: T0, existing: [], seq: 1 });
const e2 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "b" } }, { clientOpId: "a2", now: T0, existing: [], seq: 2 });
const e3 = createEntry(A, { opType: "note.create", payload: { workOrderId: "wo-2", kind: "general", text: "c" } }, { clientOpId: "b1", now: T0, existing: [], seq: 3 });
const b = selectBatch([{ ...e1, nextAttemptAt: at(60_000).toISOString(), attempts: 1 }, e2, e3], [], at(1000));
ok(b.entries.map((e) => e.clientOpId).join() === "b1", "Op im Backoff blockiert nur Folge-Ops desselben Auftrags");
}
console.log("\n— Blob-Upload vor der Op —");
{
const store = createMemoryStore();
const clientId = randomUUID();
const ref = await enqueueBlob(store, A, { clientId, workOrderId: "wo-1", kind: "photo", blob: new Blob([new Uint8Array(1024)], { type: "image/jpeg" }), fileName: "f.jpg" }, at(0));
ok(ref === blobRef(clientId), "Blob-Referenz blob:<clientId>");
const op = await enqueueOp(store, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", clientId: randomUUID(), documentId: ref, phase: "during" } }, randomUUID(), at(1));
ok(op.blobRefs.length === 1 && (await store.getBlob(clientId))?.opClientOpId === op.clientOpId, "Op mit Blob verknüpft");
ok(selectBatch(await store.listOps(ctxKeyOf(A)), await store.listBlobs(ctxKeyOf(A)), at(2)).entries.length === 0, "ohne Upload wird die Op nicht gesendet");
ok(summarize(await store.listOps(ctxKeyOf(A)), await store.listBlobs(ctxKeyOf(A))).pendingUploads === 1, "ausstehender Upload gezählt");
const fake = fakeTransport((o) => applied(o));
const pass = await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) });
ok(fake.log.indexOf(`upload:${clientId}`) === 0 && fake.log.indexOf("op:photo.attach") === 1, "Upload vor photo.attach");
ok(fake.batches[0][0].payload.documentId === `doc-${clientId.slice(0, 8)}`, "Payload auf Server-documentId umgeschrieben");
ok(pass.applied === 1 && (await store.getBlob(clientId)) === null, "nach applied: Blob lokal freigegeben");
const store2 = createMemoryStore();
const c2 = randomUUID();
const ref2 = await enqueueBlob(store2, A, { clientId: c2, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f.jpg" }, at(0));
await enqueueOp(store2, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: ref2 } }, randomUUID(), at(1));
const note = await enqueueOp(store2, A, { opType: "note.create", payload: { workOrderId: "wo-9", kind: "general", text: "unabhängig" } }, randomUUID(), at(2));
const bad = fakeTransport((o) => applied(o), { uploadError: "invalid" });
await runSyncPass({ store: store2, ctx: A, transport: bad.transport, deviceId: "d", now: () => at(10) });
const ops2 = await store2.listOps(ctxKeyOf(A));
const photoOp = ops2.find((o) => o.opType === "photo.attach")!;
ok(photoOp.status === "rejected" && problemKey(photoOp) === "upload", "Upload endgültig abgelehnt → Op rejected mit Klartext");
ok(ops2.find((o) => o.clientOpId === note.clientOpId)?.status === "applied", "unabhängige Op trotzdem gesendet");
const store3 = createMemoryStore();
const c3 = randomUUID();
await enqueueOp(store3, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", documentId: await enqueueBlob(store3, A, { clientId: c3, workOrderId: "wo-1", kind: "photo", blob: new Blob(["x"]), fileName: "f" }, at(0)) } }, randomUUID(), at(1));
const offline = fakeTransport((o) => applied(o), { uploadError: "network" });
const p = await runSyncPass({ store: store3, ctx: A, transport: offline.transport, deviceId: "d", now: () => at(10), random: fixedRandom });
const blob3 = await store3.getBlob(c3);
ok(p.stopped === "network" && blob3?.status === "failed" && blob3.nextAttemptAt === at(2010).toISOString() && offline.batches.length === 0, "Upload ohne Netz → Backoff, Op wartet");
}
console.log("\n— idMap / verkettete baseVersion —");
{
ok(
JSON.stringify(applyIdMapToPayload({ clientId: "c1", photoId: "c1", nested: { ref: "c1" }, list: ["c1", "x"] }, { c1: "srv-1" })) ===
JSON.stringify({ clientId: "c1", photoId: "srv-1", nested: { ref: "srv-1" }, list: ["srv-1", "x"] }),
"idMap ersetzt Referenzen, eigenes clientId-Feld bleibt",
);
const store = createMemoryStore();
const photoClient = randomUUID();
const first = await enqueueOp(store, A, { opType: "photo.attach", payload: { workOrderId: "wo-1", clientId: photoClient, documentId: "doc-1" } }, randomUUID(), at(0));
const second = await enqueueOp(store, A, { opType: "material.upsert", payload: { workOrderId: "wo-1", clientId: randomUUID(), name: "Rohr", quantity: 1, unit: "m", usageStatus: "additional", photoId: photoClient } }, randomUUID(), at(1));
const ops = await store.listOps(ctxKeyOf(A));
const { changed, idMap } = applyResults(ops, [ops[0]], [{ clientOpId: first.clientOpId, status: "applied", idMap: { [photoClient]: "photo-srv" } }], at(5));
const rewritten = changed.find((c) => c.clientOpId === second.clientOpId);
ok(idMap[photoClient] === "photo-srv" && rewritten?.payload.photoId === "photo-srv", "idMap auf wartende Ops angewendet (client id → server id)");
const chain = createMemoryStore();
const start = await enqueueOp(chain, A, { opType: "session.start", payload: { workOrderId: "wo-1", mode: "work", clientId: randomUUID() } }, randomUUID(), at(0));
const complete = await enqueueOp(chain, A, { opType: "work_order.transition", baseVersion: 3, payload: { workOrderId: "wo-1", to: "technically_completed" } }, randomUUID(), at(1));
ok(complete.chainedBase === true, "Statuswechsel hinter eigener Session-Op → baseVersion verkettet");
const fake = fakeTransport((op) => (op.opType === "session.start" ? applied(op, { entityVersion: 5 }) : op.baseVersion === 5 ? applied(op, { entityVersion: 6 }) : { clientOpId: op.clientOpId, status: "conflict", errorCode: "conflict" }));
await runSyncPass({ store: chain, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10), maxBatch: 1 });
const chained = (await chain.listOps(ctxKeyOf(A))).find((o) => o.clientOpId === complete.clientOpId);
ok(fake.batches.length === 2 && fake.batches[1][0].baseVersion === 5 && chained?.status === "applied", "baseVersion aus Server-Ergebnis der Vorgänger-Op übernommen");
ok(start.chainedBase === undefined, "additive/Session-Op ohne Verkettung");
}
console.log("\n— Mandanten-/User-Trennung der lokalen Stores —");
{
const store = createMemoryStore();
await enqueueOp(store, A, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "A" } }, randomUUID(), at(0));
await enqueueOp(store, B, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "B" } }, randomUUID(), at(1));
await enqueueOp(store, A2, { opType: "note.create", payload: { workOrderId: "wo-1", kind: "general", text: "A2" } }, randomUUID(), at(2));
await enqueueBlob(store, B, { clientId: randomUUID(), workOrderId: "wo-1", kind: "photo", blob: new Blob(["b"]), fileName: "b" }, at(3));
await store.replaceBundle(ctxKeyOf(A), toBundleRecords(ctxKeyOf(A), [order("wo-1")], at(0).toISOString()));
await store.replaceBundle(ctxKeyOf(B), toBundleRecords(ctxKeyOf(B), [order("wo-b")], at(0).toISOString()));
await store.setMeta(ctxKeyOf(A), "draft:note:wo-1", { text: "geheim A" });
const opsA = await store.listOps(ctxKeyOf(A));
ok(opsA.length === 1 && opsA[0].payload.text === "A" && opsA[0].tenantId === "tenant-a" && opsA[0].userId === "user-1", "Outbox liefert nur Ops des eigenen Mandanten+Users");
ok((await store.listOps(ctxKeyOf(A2))).length === 1 && (await store.listOps(ctxKeyOf(B)))[0].payload.text === "B", "anderer User desselben Mandanten / anderer Mandant getrennt");
ok((await store.listBlobs(ctxKeyOf(A))).length === 0 && (await store.listBlobs(ctxKeyOf(B))).length === 1, "Blobs je Kontext getrennt");
ok((await store.listBundle(ctxKeyOf(B))).map((r) => r.workOrderId).join() === "wo-b", "Bundle je Kontext getrennt");
ok((await store.getMeta(ctxKeyOf(B), "draft:note:wo-1")) === null, "Entwürfe je Kontext getrennt");
await store.replaceBundle(ctxKeyOf(A), [...toBundleRecords(ctxKeyOf(B), [order("wo-injected")], T0.toISOString()), ...toBundleRecords(ctxKeyOf(A), [order("wo-1")], T0.toISOString())]);
ok((await store.listBundle(ctxKeyOf(B))).every((r) => r.workOrderId === "wo-b") && (await store.listBundle(ctxKeyOf(A))).length === 1, "replaceBundle schreibt keine Datensätze fremder Kontexte");
await store.clearContext(ctxKeyOf(A));
ok((await store.listOps(ctxKeyOf(A))).length === 0 && (await store.listBundle(ctxKeyOf(A))).length === 0 && (await store.getMeta(ctxKeyOf(A), "draft:note:wo-1")) === null, "Kontext A gelöscht (Logout)");
ok((await store.listOps(ctxKeyOf(B))).length === 1 && (await store.listOps(ctxKeyOf(A2))).length === 1, "Daten anderer Kontexte bleiben erhalten");
ok(!(await store.listContexts()).includes(ctxKeyOf(A)), "Kontext A nicht mehr gelistet");
// a pass for context A never sends ops of B
const fake = fakeTransport((o) => applied(o));
await runSyncPass({ store, ctx: A, transport: fake.transport, deviceId: "d", now: () => at(10) });
ok(fake.batches.length === 0, "Sync-Pass von A sendet keine Ops von B/A2");
}
console.log("\n— Optimistische Auftragsansicht —");
{
const record = toBundleRecords("tenant-a:user-1", [order("wo-1", { status: "accepted" })], at(0).toISOString())[0];
const mk = (opType: OutboxEntry["opType"], payload: Record<string, unknown>, seq: number, status: OutboxEntry["status"] = "queued") => ({
...createEntry(A, { opType, payload }, { clientOpId: `op-${seq}`, now: at(seq * 1000), existing: [], seq }),
status,
...(status === "applied" ? { appliedAt: at(seq * 1000).toISOString() } : {}),
});
const ops = [
mk("session.start", { workOrderId: "wo-1", mode: "work" }, 1),
mk("note.create", { workOrderId: "wo-1", clientId: "n-1", kind: "work_done", text: "Montiert" }, 2),
mk("checklist.toggle", { workOrderId: "wo-1", itemId: "chk-1", checked: true }, 3),
mk("photo.attach", { workOrderId: "wo-1", clientId: "p-1", documentId: blobRef("b-1"), photoRequirementId: "req-1" }, 4),
mk("session.pause", { workOrderId: "wo-1" }, 5),
mk("note.create", { workOrderId: "wo-1", kind: "general", text: "abgelehnt" }, 6, "rejected"),
mk("note.create", { workOrderId: "wo-2", kind: "general", text: "anderer Auftrag" }, 7),
];
const view = buildOrderView(record, ops);
ok(view.status === "paused" && view.local.session === "paused" && view.statusGroup === "in_progress", "Zeiten/Status optimistisch angewendet");
ok(view.local.notes.length === 1 && view.local.notes[0].text === "Montiert" && view.local.notes[0].pending, "Notiz lokal sichtbar (ausstehend), abgelehnte nicht");
ok(view.checklistItems[0].checked && view.photoRequirements[0]._count.photos === 1 && view.local.photos[0].blobClientId === "b-1", "Checkliste und Pflichtfoto lokal aktualisiert");
ok(view.local.pendingOps === 5 && view.local.rejected, "ausstehende Ops gezählt, Fehlerhinweis gesetzt");
ok(record.data.status === "accepted" && !record.data.checklistItems[0].checked, "Server-Snapshot unverändert");
const afterPull = buildOrderView({ ...record, syncedAt: at(60_000).toISOString() }, ops.map((o) => ({ ...o, status: "applied" as const, appliedAt: at(10_000).toISOString() })));
ok(afterPull.status === "accepted" && afterPull.local.notes.length === 0, "nach neuem Pull gilt der Server-Stand");
ok(prunable(ops.map((o) => ({ ...o, status: "applied" as const, appliedAt: at(10_000).toISOString() })), at(60_000).toISOString()).length === ops.length, "angewendete Ops nach Pull aufräumbar");
}
console.log("\n— Vorab-Download / Veraltet / Dokument-Cache —");
{
const now = new Date("2026-09-14T10:00:00+02:00");
const d = (days: number, h = 9) => {
const x = new Date(now);
x.setHours(h, 0, 0, 0);
x.setDate(x.getDate() + days);
return x.toISOString();
};
const orders = [
order("today", { plannedStart: d(0), plannedEnd: d(0, 17) }),
order("in3", { plannedStart: d(3) }),
order("in5", { plannedStart: d(5) }),
order("running", { status: "in_progress", plannedStart: d(-10) }),
order("past-planned", { plannedStart: d(-2), plannedEnd: d(-2, 12) }),
order("undated"),
];
const sel = selectOfflineOrders(orders, now).map((o) => o.id);
ok(sel.join() === "today,in3,running", "heute + 3 Tage + laufende Aufträge ausgewählt");
ok(!isBundleStale(new Date(now.getTime() - 6 * 864e5).toISOString(), now, 7) && isBundleStale(new Date(now.getTime() - 8 * 864e5).toISOString(), now, 7), "Bundle nach OFFLINE_MAX_DAYS als veraltet markiert");
ok(parseMaxDays(undefined) === 7 && parseMaxDays("14") === 14 && parseMaxDays("0") === 7 && parseMaxDays("abc") === 7, "OFFLINE_MAX_DAYS mit Default 7");
const docs = selectOfflineDocuments([
{ id: "1", category: "technical_drawing", fileSize: 1e6 },
{ id: "2", category: "assembly_instructions", fileSize: 25 * 1024 * 1024 },
{ id: "3", category: "safety_document", fileSize: 26 * 1024 * 1024 },
{ id: "4", category: "order_confirmation", fileSize: 1e5 },
{ id: "1", category: "technical_drawing", fileSize: 1e6 },
]);
ok(docs.map((x) => x.id).join() === "1,2", "Zeichnung/Montageanleitung/Sicherheit ≤ 25 MB, ohne Dubletten");
const MB = 1024 * 1024;
const ev = selectEvictions(
[
{ url: "a", size: 100 * MB, lastUsed: 1 },
{ url: "b", size: 150 * MB, lastUsed: 3 },
{ url: "c", size: 40 * MB, lastUsed: 2 },
],
DOC_CACHE_MAX_BYTES,
20 * MB,
);
ok(ev.join() === "a", "LRU: ältester Eintrag verdrängt, bis 300 MB passen");
ok(selectEvictions([{ url: "a", size: 10, lastUsed: 1 }], DOC_CACHE_MAX_BYTES).length === 0, "unter dem Limit keine Verdrängung");
ok(selectEvictions([{ url: "keep", size: 200 * MB, lastUsed: 1 }, { url: "old", size: 200 * MB, lastUsed: 5 }], DOC_CACHE_MAX_BYTES, 0, new Set(["keep"])).join() === "old", "Dokumente des aktuellen Bundles zuletzt verdrängt");
}
console.log("\n— Service Worker (public/sw.js) —");
{
const sw = readFileSync(new URL("../public/sw.js", import.meta.url), "utf8");
ok(sw.includes(`"${DOC_CACHE}"`) && sw.includes(`"${PAGE_CACHE}"`) && sw.includes(String(DOC_CACHE_MAX_BYTES / 1024 / 1024)), "Cache-Namen/Limit in sw.js und doc-cache.ts identisch");
ok(/request\.method !== "GET"/.test(sw), "nur GET-Anfragen werden behandelt (keine Mutationen)");
ok(/NEVER_CACHE/.test(sw) && sw.includes('"/api/v1/sync"') && sw.includes('"/api/auth"') && sw.includes('"/login"'), "Sync-API, Auth-Routen und Login ausgeschlossen");
ok(sw.includes('"/m/offline"') && sw.includes("/_next/static/"), "Offline-Fallback und statische Assets berücksichtigt");
ok(sw.includes("SKIP_WAITING"), "Update-Flow über SKIP_WAITING");
}
console.log(`\n${failures === 0 ? "Alle Prüfungen bestanden." : `${failures} Prüfung(en) fehlgeschlagen.`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});
+193
View File
@@ -0,0 +1,193 @@
// Lane L7 „Offline & PWA" — Ende-zu-Ende gegen den echten Sync-Service (ohne HTTP):
// Outbox (In-Memory-Store) + Sync-Engine + applyOperations/storeFieldUpload.
// - 20 Ops offline erfasst (10 Tage alt, älter als OFFLINE_MAX_DAYS) → Foto-Upload vor der Op →
// EIN Batch → alle applied, clientCreatedAt serverseitig protokolliert
// - Wiederholung desselben Batches → alle duplicate, keine Doppelanlagen
// - Konflikt (Auftrag im Büro geändert) stoppt keine unabhängige Op
// - Mandantentrennung: Mandant B kann Auftrag von A weder lesen (bundle) noch ändern (Ops/Upload)
// - Rollen/Scope: Monteur ohne Zuweisung → not_found
//
// Lauf: npx tsx scripts/test-offline-sync-e2e.ts (lokale Postgres-DB aus .env; S3 optional)
import "dotenv/config";
import { randomUUID } from "node:crypto";
import sharp from "sharp";
import { prisma } from "../src/server/db";
import { closeJobQueues } from "../src/server/jobs/queues";
import { applyOperations } from "../src/server/services/sync/apply";
import { storeFieldUpload } from "../src/server/services/field/uploads";
import { getFieldBundle } from "../src/server/services/field/queries";
import type { ServiceCtx } from "../src/server/services/context";
import type { SyncOperationInput } from "../src/lib/sync/envelope";
import { createMemoryStore } from "../src/lib/offline/memory-store";
import { blobRef, selectBatch } from "../src/lib/offline/outbox-core";
import { enqueueBlob, enqueueOp, runSyncPass, type Transport } from "../src/lib/offline/sync-engine";
import { selectOfflineOrders } from "../src/lib/offline/bundle-core";
import { ctxKeyOf, type OfflineContext, type OfflineStore } from "../src/lib/offline/types";
import { createFixture, failures, ok } from "./lib/einsatz-fixture";
type Stats = { batches: SyncOperationInput[][]; uploads: number };
/** Transport that calls the server services in-process with the given (authenticated) context. */
function serverTransport(ctx: ServiceCtx): { transport: Transport; stats: Stats } {
const stats: Stats = { batches: [], uploads: 0 };
const transport: Transport = {
async sendBatch(deviceId, operations) {
stats.batches.push(operations);
return { ok: true, response: await applyOperations(ctx, { deviceId, operations }) };
},
async upload(blob) {
stats.uploads++;
try {
const r = await storeFieldUpload(
ctx,
{ clientId: blob.clientId, workOrderId: blob.workOrderId, kind: blob.kind },
{ bytes: Buffer.from(await blob.blob.arrayBuffer()), name: blob.fileName, type: blob.blob.type },
null,
);
return { ok: true, documentId: r.documentId };
} catch (err) {
const code = (err as { code?: string }).code;
return { ok: false, error: code === "not_found" || code === "forbidden" || code === "invalid" ? code : "internal" };
}
},
};
return { transport, stats };
}
const localCtx = (ctx: ServiceCtx): OfflineContext => ({ tenantId: ctx.tenantId, userId: ctx.userId });
async function main() {
const f = await createFixture("l7e2e");
const wo = f.orderA;
const A = localCtx(f.ctxTech);
const store: OfflineStore = createMemoryStore();
try {
const jpeg = await sharp({ create: { width: 800, height: 600, channels: 3, background: { r: 30, g: 90, b: 160 } } }).jpeg().toBuffer();
console.log("\n— Vorab-Download (Bundle im Scope) —");
const bundle = await getFieldBundle(f.ctxTech);
const offlineOrders = selectOfflineOrders(bundle.orders.map((o) => ({ ...o, plannedStart: o.plannedStart?.toISOString() ?? null, plannedEnd: o.plannedEnd?.toISOString() ?? null })), new Date());
ok(offlineOrders.some((o) => o.id === wo.id), "heutiger Auftrag des Monteurs wird offline gespeichert");
ok(!(await getFieldBundle(f.ctxB)).orders.some((o) => o.id === wo.id), "Mandant B erhält Auftrag von A nicht im Bundle");
ok(!(await getFieldBundle(f.ctxOutsider)).orders.some((o) => o.id === wo.id), "Monteur ohne Zuweisung erhält den Auftrag nicht im Bundle");
console.log("\n— 20 Ops offline erfassen —");
const offlineAt = Date.now() - 10 * 24 * 60 * 60 * 1000; // older than OFFLINE_MAX_DAYS (7)
let minute = 0;
const when = () => new Date(offlineAt + minute++ * 60_000);
const add = (op: Parameters<typeof enqueueOp>[2]) => enqueueOp(store, A, op, randomUUID(), when());
await add({ opType: "work_order.transition", baseVersion: wo.version, payload: { workOrderId: wo.id, to: "accepted" } });
await add({ opType: "session.start", payload: { workOrderId: wo.id, mode: "work", clientId: randomUUID(), offline: true } });
for (let i = 1; i <= 8; i++) await add({ opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "work_done", text: `Offline-Notiz ${i}` } });
await add({ opType: "checklist.toggle", payload: { workOrderId: wo.id, itemId: wo.checklistItems[0].id, checked: true } });
await add({ opType: "material.upsert", payload: { workOrderId: wo.id, materialPlanId: wo.materialPlans[0].id, quantity: 10, unit: "m", usageStatus: "fully_used" } });
await add({ opType: "material.upsert", payload: { workOrderId: wo.id, clientId: randomUUID(), name: "Dichtband", quantity: 2, unit: "Rolle", usageStatus: "additional", deviationReason: "Undichte Verschraubung" } });
const photoClient = randomUUID();
const ref = await enqueueBlob(store, A, { clientId: photoClient, workOrderId: wo.id, kind: "photo", blob: new Blob([new Uint8Array(jpeg)], { type: "image/jpeg" }), fileName: "typenschild.jpg" }, when());
ok(ref === blobRef(photoClient), "Foto in die Upload-Warteschlange aufgenommen");
await add({ opType: "photo.attach", payload: { workOrderId: wo.id, clientId: randomUUID(), documentId: ref, phase: "after", photoRequirementId: wo.photoRequirements[0].id } });
await add({ opType: "session.pause", payload: { workOrderId: wo.id } });
await add({ opType: "session.resume", payload: { workOrderId: wo.id } });
for (let i = 9; i <= 11; i++) await add({ opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: `Offline-Notiz ${i}` } });
await add({ opType: "session.end", payload: { workOrderId: wo.id } });
const queued = await store.listOps(ctxKeyOf(A));
ok(queued.length === 20 && queued.every((o) => o.status === "queued"), "20 Ops lokal in der Outbox (queued)");
const beforeUpload = selectBatch(queued, await store.listBlobs(ctxKeyOf(A)), new Date());
ok(beforeUpload.entries.length === 13, "vor dem Upload: Ops ab dem Foto warten (Reihenfolge je Auftrag)");
ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 0, "offline: noch nichts auf dem Server");
console.log("\n— Verbindung da: ein Batch —");
const tech = serverTransport(f.ctxTech);
const pass = await runSyncPass({ store, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" });
ok(tech.stats.uploads === 1 && tech.stats.batches.length === 1 && tech.stats.batches[0].length === 20, "1 Upload, dann genau 1 Batch mit 20 Ops");
ok(pass.applied === 20 && pass.conflicts === 0 && pass.rejected === 0, `alle 20 Ops applied (applied=${pass.applied}, conflict=${pass.conflicts}, rejected=${pass.rejected})`);
const afterPass = await store.listOps(ctxKeyOf(A));
ok(afterPass.every((o) => o.status === "applied" && o.result?.status === "applied"), "lokaler Status aller Ops: applied");
if (pass.applied !== 20) console.log(afterPass.filter((o) => o.status !== "applied").map((o) => `${o.opType}: ${o.status} ${o.lastError?.code} ${o.lastError?.message}`));
const photoOp = tech.stats.batches[0].find((o) => o.opType === "photo.attach");
ok(!!photoOp && typeof photoOp.payload.documentId === "string" && !String(photoOp.payload.documentId).startsWith("blob:"), "Foto-Op mit Server-documentId gesendet");
ok((await store.getBlob(photoClient)) === null, "Foto-Blob nach erfolgreicher Übertragung lokal gelöscht");
ok(Object.keys(pass.idMap).length >= 11, "idMap für angelegte Datensätze erhalten");
const clientOpIds = queued.map((o) => o.clientOpId);
const stored = await prisma.syncOperation.findMany({ where: { tenantId: f.tenantA.id, clientOpId: { in: clientOpIds } } });
ok(stored.length === 20 && stored.every((s) => s.status === "applied"), "20 SyncOperations serverseitig protokolliert (applied)");
const drift = Math.max(...stored.map((s) => Math.abs(s.clientCreatedAt.getTime() - new Date(queued.find((q) => q.clientOpId === s.clientOpId)!.clientCreatedAt).getTime())));
ok(drift < 1000 && stored.every((s) => s.clientCreatedAt.getTime() < Date.now() - 7 * 864e5), "alte Ops trotzdem angewendet, clientCreatedAt (10 Tage alt) protokolliert");
ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 11, "11 Notizen angelegt");
ok((await prisma.photo.count({ where: { workOrderId: wo.id } })) === 1, "Foto angehängt");
ok((await prisma.materialUsage.count({ where: { workOrderId: wo.id } })) === 2, "Material (Plan + Zusatz) erfasst");
ok((await prisma.checklistItem.findUniqueOrThrow({ where: { id: wo.checklistItems[0].id } })).checked, "Checkliste abgehakt");
ok((await prisma.workSession.count({ where: { workOrderId: wo.id, status: "ended" } })) === 1, "Session gestartet, pausiert, fortgesetzt, beendet");
console.log("\n— Wiederholung → duplicate —");
const replay = await applyOperations(f.ctxTech, { deviceId: "l7-e2e-device", operations: tech.stats.batches[0] });
ok(replay.results.length === 20 && replay.results.every((r) => r.status === "duplicate"), "gleicher Batch erneut → 20× duplicate");
// re-queue with the payloads as sent (the local photo blob was already released after applied)
const sentPayload = new Map(tech.stats.batches[0].map((o) => [o.clientOpId, o.payload]));
for (const e of afterPass) await store.putOp({ ...e, status: "queued", appliedAt: null, result: null, payload: sentPayload.get(e.clientOpId) ?? e.payload, blobRefs: [] });
const replayPass = await runSyncPass({ store, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" });
ok(replayPass.applied === 20 && (await store.listOps(ctxKeyOf(A))).every((o) => o.status === "applied" && o.result?.status === "duplicate"), "Outbox-Wiederholung: Server meldet duplicate, lokal applied");
ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === 11 && (await prisma.photo.count({ where: { workOrderId: wo.id } })) === 1, "keine Doppelanlagen");
ok((await prisma.syncOperation.count({ where: { tenantId: f.tenantA.id, clientOpId: { in: clientOpIds } } })) === 20, "SyncOperations nicht doppelt");
console.log("\n— Konflikt stoppt keine unabhängige Op —");
const current = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
await prisma.workOrder.update({ where: { id: wo.id }, data: { version: { increment: 1 }, technicianNotes: "Im Büro geändert" } });
const conflictStore = createMemoryStore();
const tr = await enqueueOp(conflictStore, A, { opType: "work_order.transition", baseVersion: current.version, payload: { workOrderId: wo.id, to: "paused" } }, randomUUID());
const note = await enqueueOp(conflictStore, A, { opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: "Nach dem Konflikt" } }, randomUUID());
const cPass = await runSyncPass({ store: conflictStore, ctx: A, transport: tech.transport, deviceId: "l7-e2e-device" });
const cOps = new Map((await conflictStore.listOps(ctxKeyOf(A))).map((o) => [o.clientOpId, o]));
ok(cPass.conflicts === 1 && cOps.get(tr.clientOpId)?.status === "conflict", "veraltete Statusänderung → conflict");
ok(cOps.get(note.clientOpId)?.status === "applied", "Notiz danach trotzdem applied");
const afterConflict = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
ok(afterConflict.status === current.status && afterConflict.technicianNotes === "Im Büro geändert", "Büro-Stand nicht überschrieben");
ok((await prisma.syncOperation.findFirst({ where: { tenantId: f.tenantA.id, clientOpId: tr.clientOpId } }))?.status === "conflict", "Konflikt fürs Backoffice protokolliert");
console.log("\n— Mandantentrennung —");
const B = localCtx(f.ctxB);
const notesBefore = await prisma.activityNote.count({ where: { workOrderId: wo.id } });
const bNote = await enqueueOp(store, B, { opType: "note.create", payload: { workOrderId: wo.id, clientId: randomUUID(), kind: "general", text: "Mandant B" } }, randomUUID());
const bTransition = await enqueueOp(store, B, { opType: "work_order.transition", baseVersion: afterConflict.version, payload: { workOrderId: wo.id, to: "cancelled" } }, randomUUID());
const bBlob = randomUUID();
const bPhoto = await enqueueOp(store, B, { opType: "photo.attach", payload: { workOrderId: wo.id, documentId: await enqueueBlob(store, B, { clientId: bBlob, workOrderId: wo.id, kind: "photo", blob: new Blob([new Uint8Array(jpeg)], { type: "image/jpeg" }), fileName: "b.jpg" }) } }, randomUUID());
ok((await store.listOps(ctxKeyOf(A))).every((o) => o.tenantId === f.tenantA.id) && (await store.listOps(ctxKeyOf(B))).length === 3, "lokale Outbox je Mandant getrennt");
const bSrv = serverTransport(f.ctxB);
await runSyncPass({ store, ctx: B, transport: bSrv.transport, deviceId: "device-b" });
const bOps = new Map((await store.listOps(ctxKeyOf(B))).map((o) => [o.clientOpId, o]));
ok(bOps.get(bNote.clientOpId)?.status === "rejected" && bOps.get(bNote.clientOpId)?.lastError?.code === "not_found", "B: Notiz auf Auftrag von A → rejected not_found");
ok(bOps.get(bTransition.clientOpId)?.status === "rejected" && bOps.get(bTransition.clientOpId)?.lastError?.code === "not_found", "B: Statusänderung auf Auftrag von A → not_found (kein Versions-Leak)");
ok(bOps.get(bPhoto.clientOpId)?.status === "rejected" && (await store.getBlob(bBlob))?.status === "failed", "B: Foto-Upload auf Auftrag von A abgelehnt");
ok(bSrv.stats.batches.flat().every((o) => o.opType !== "photo.attach"), "B: Foto-Op ohne Upload nie gesendet");
const woAfterB = await prisma.workOrder.findUniqueOrThrow({ where: { id: wo.id } });
ok((await prisma.activityNote.count({ where: { workOrderId: wo.id } })) === notesBefore && woAfterB.status !== "cancelled", "Auftrag von A unverändert");
ok((await store.listOps(ctxKeyOf(A))).length === 20, "Outbox von A durch B-Sync unberührt");
console.log("\n— Rollen/Scope —");
const O = localCtx(f.ctxOutsider);
const oStore = createMemoryStore();
const oNote = await enqueueOp(oStore, O, { opType: "note.create", payload: { workOrderId: wo.id, kind: "general", text: "ohne Zuweisung" } }, randomUUID());
const oSession = await enqueueOp(oStore, O, { opType: "session.start", payload: { workOrderId: wo.id, mode: "work", clientId: randomUUID() } }, randomUUID());
await runSyncPass({ store: oStore, ctx: O, transport: serverTransport(f.ctxOutsider).transport, deviceId: "device-o" });
const oOps = new Map((await oStore.listOps(ctxKeyOf(O))).map((o) => [o.clientOpId, o]));
ok(oOps.get(oNote.clientOpId)?.lastError?.code === "not_found" && oOps.get(oSession.clientOpId)?.lastError?.code === "not_found", "Monteur ohne Zuweisung → not_found, endgültig (kein Retry)");
ok([...oOps.values()].every((o) => o.status === "rejected" && o.nextAttemptAt === null), "keine Wiederholungsschleife für Scope-Fehler");
ok((await prisma.workSession.count({ where: { workOrderId: wo.id, userId: f.outsider.id } })) === 0, "keine Session für den fremden Monteur angelegt");
} finally {
await f.cleanup();
await closeJobQueues().catch(() => undefined);
await prisma.$disconnect();
}
console.log(`\n${failures === 0 ? "Alle Prüfungen bestanden." : `${failures} Prüfung(en) fehlgeschlagen.`}`);
process.exit(failures === 0 ? 0 : 1);
}
main().catch(async (err) => {
console.error(err);
await prisma.$disconnect().catch(() => undefined);
process.exit(1);
});
+5 -7
View File
@@ -1,6 +1,5 @@
import Link from "next/link"; import Link from "next/link";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { LogOut } from "lucide-react";
import { auth, signOut } from "@/server/auth"; import { auth, signOut } from "@/server/auth";
import { prisma } from "@/server/db"; import { prisma } from "@/server/db";
import { can } from "@/server/services/context"; import { can } from "@/server/services/context";
@@ -8,6 +7,8 @@ import { activeTeamIds } from "@/server/services/work-orders/visibility";
import { fieldPageContext } from "@/server/services/field/page-context"; import { fieldPageContext } from "@/server/services/field/page-context";
import { UiLocaleSwitcher } from "@/components/ui-locale-switcher"; import { UiLocaleSwitcher } from "@/components/ui-locale-switcher";
import { btnSecondary, card } from "@/components/field/ui"; import { btnSecondary, card } from "@/components/field/ui";
import { InstallHint } from "@/components/offline/install-hint";
import { LogoutForm } from "@/components/offline/logout-form";
/** `/m/profile` — name, team, company, language, sign out. */ /** `/m/profile` — name, team, company, language, sign out. */
export default async function ProfilePage() { export default async function ProfilePage() {
@@ -62,12 +63,9 @@ export default async function ProfilePage() {
{t("backoffice")} {t("backoffice")}
</Link> </Link>
)} )}
<form action={logout}> <InstallHint />
<button type="submit" className={btnSecondary}> {/* L7: deletes the local offline data of this tenant/user first (warning if unsent entries exist) */}
<LogOut className="size-5" aria-hidden /> <LogoutForm action={logout} label={t("logout")} />
{t("logout")}
</button>
</form>
</main> </main>
); );
} }
+2
View File
@@ -5,6 +5,7 @@ import { CraftviaLogo } from "@/components/brand/craftvia-logo";
import { AccountInactiveNotice } from "@/components/account-inactive-notice"; import { AccountInactiveNotice } from "@/components/account-inactive-notice";
import { BottomNav } from "@/components/field/bottom-nav"; import { BottomNav } from "@/components/field/bottom-nav";
import { OnlineBadge } from "@/components/field/online-badge"; import { OnlineBadge } from "@/components/field/online-badge";
import { OfflineRuntime } from "@/components/offline/offline-runtime";
/** /**
* Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice * Mobile shell `/m` (ARCHITEKTUR §5): same session/account/MFA checks as the backoffice
@@ -24,6 +25,7 @@ export default async function FieldShell({ children }: Readonly<{ children: Reac
</Link> </Link>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{/* Slot für L6: <NotificationBell variant="mobile" /> aus src/components/notifications/bell.tsx */} {/* Slot für L6: <NotificationBell variant="mobile" /> aus src/components/notifications/bell.tsx */}
<OfflineRuntime />
<OnlineBadge /> <OnlineBadge />
</div> </div>
</header> </header>
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „field" für die Offline-Ansicht (Lane L7). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("field");
return <>{children}</>;
}
+17
View File
@@ -0,0 +1,17 @@
import { getTranslations } from "next-intl/server";
import { OfflineView } from "@/components/offline/offline-view";
/**
* `/m/offline` — offline fallback (lane L7): order list/detail from the local bundle. Cached by the
* service worker; the content is rendered on the client from IndexedDB, so the cached HTML stays
* valid while offline.
*/
export default async function OfflinePage() {
const t = await getTranslations("offline.view");
return (
<main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("title")}</h1>
<OfflineView />
</main>
);
}
+7
View File
@@ -0,0 +1,7 @@
import { requireModule } from "@/server/modules";
/** Modul-Gate „field" für die Synchronisationsseite (Lane L7). */
export default async function ModuleLayout({ children }: Readonly<{ children: React.ReactNode }>) {
await requireModule("field");
return <>{children}</>;
}
+7 -13
View File
@@ -1,23 +1,17 @@
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { OnlineBadge } from "@/components/field/online-badge"; import { SyncPanel } from "@/components/offline/sync-panel";
import { card } from "@/components/field/ui";
/** /**
* PLACEHOLDER (lane L4) — `/m/sync` belongs to lane L7 (Offline/PWA), which replaces this page * `/m/sync` (lane L7, Spec §22/§23.4, US-012): connection, last sync, pending changes and uploads
* with the outbox status, errors and conflicts. Until then it shows the connection state and that * with progress, errors/conflicts in plain language (retry / discard), storage usage, reset. The
* ops are sent immediately (src/lib/field/client-ops.ts). * data lives on the device (IndexedDB), so the panel is a client component.
*/ */
export default async function SyncPlaceholderPage() { export default async function SyncPage() {
const t = await getTranslations("field.sync"); const t = await getTranslations("offline.sync");
return ( return (
<main className="space-y-4 p-4"> <main className="space-y-4 p-4">
<h1 className="text-[26px]">{t("title")}</h1> <h1 className="text-[26px]">{t("title")}</h1>
<section className={`${card} space-y-3`}> <SyncPanel />
<p className="text-[13px] font-semibold text-muted-foreground">{t("status")}</p>
<OnlineBadge large />
<p className="text-[15px]">{t("immediate")}</p>
<p className="text-[14px] text-muted-foreground">{t("offlineHint")}</p>
</section>
</main> </main>
); );
} }
+9 -35
View File
@@ -1,55 +1,33 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { CircleCheck, TriangleAlert } from "lucide-react"; import { CircleCheck, TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops"; import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops";
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; import { errorKey, isQueued, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
import { useOfflineDraft } from "@/components/offline/hooks";
import { btnPrimary, chip, inputClass, noticeError, noticeOk } from "./ui"; import { btnPrimary, chip, inputClass, noticeError, noticeOk } from "./ui";
type Draft = { kind: NoteKind; text: string; clientId: string }; type Draft = { kind: NoteKind; text: string; clientId: string };
const draftKey = (workOrderId: string) => `craftvia.field.noteDraft.${workOrderId}`;
function readDraft(workOrderId: string): Draft | null {
try {
const raw = localStorage.getItem(draftKey(workOrderId));
return raw ? (JSON.parse(raw) as Draft) : null;
} catch {
return null;
}
}
/** /**
* Activity note (Spec §12.3): kind as large chips + text. The draft (incl. its clientId, so a retry * Activity note (Spec §12.3): kind as large chips + text. The draft (incl. its clientId, so a retry
* stays idempotent) is kept in localStorage until the server confirmed it (US-006). * stays idempotent) is stored automatically in IndexedDB per tenant/user (lane L7) until the note
* is saved (US-006); offline the note goes into the outbox.
*/ */
export function NoteForm({ workOrderId }: { workOrderId: string }) { export function NoteForm({ workOrderId }: { workOrderId: string }) {
const t = useTranslations("field"); const t = useTranslations("field");
const router = useRouter(); const router = useRouter();
const [draft, setDraft] = useState<Draft>({ kind: "work_done", text: "", clientId: "" }); const [draft, setDraft, clearDraft] = useOfflineDraft<Draft>(`note:${workOrderId}`, { kind: "work_done", text: "", clientId: "" });
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [saved, setSaved] = useState(false); const [saved, setSaved] = useState(false);
useEffect(() => {
const stored = readDraft(workOrderId);
// restore an unsent draft after reload / connection loss (client-only storage)
// eslint-disable-next-line react-hooks/set-state-in-effect
if (stored) setDraft(stored);
}, [workOrderId]);
function update(next: Partial<Draft>) { function update(next: Partial<Draft>) {
const value = { ...draft, ...next, clientId: draft.clientId || newClientId() }; setDraft({ ...draft, ...next, clientId: draft.clientId || newClientId() });
setDraft(value);
setSaved(false); setSaved(false);
try {
localStorage.setItem(draftKey(workOrderId), JSON.stringify(value));
} catch {
// storage unavailable (private mode) — the form still works
}
} }
async function submit(e: React.FormEvent) { async function submit(e: React.FormEvent) {
@@ -64,14 +42,10 @@ export function NoteForm({ workOrderId }: { workOrderId: string }) {
setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`); setError(`${t(`errors.${errorKey(result)}`)} ${t("notes.draftKept")}`);
return; return;
} }
try {
localStorage.removeItem(draftKey(workOrderId));
} catch {
/* ignore */
}
setDraft({ kind: draft.kind, text: "", clientId: "" }); setDraft({ kind: draft.kind, text: "", clientId: "" });
await clearDraft();
setSaved(true); setSaved(true);
router.refresh(); if (!isQueued(result)) router.refresh();
} }
return ( return (
+6 -7
View File
@@ -6,9 +6,8 @@ import { useTranslations } from "next-intl";
import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react"; import { Camera, CircleCheck, Image as ImageIcon, LoaderCircle, TriangleAlert, Upload } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { PHOTO_PHASES, type PhotoPhase } from "@/lib/sync/ops"; import { PHOTO_PHASES, type PhotoPhase } from "@/lib/sync/ops";
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; import { errorKey, isQueued, isSuccess, newClientId, queueBlob, submitOp } from "@/lib/field/client-ops";
import { compressImage, currentPosition } from "@/lib/field/image"; import { compressImage, currentPosition } from "@/lib/field/image";
import { uploadFieldFile } from "@/lib/field/upload";
import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui"; import { btnPrimary, btnSecondary, chip, inputClass, noticeError, noticeOk } from "./ui";
type Option = { id: string; label: string }; type Option = { id: string; label: string };
@@ -47,18 +46,18 @@ function usePhotoSave(workOrderId: string) {
} }
setProgress({ stage: "uploading", percent: 0 }); setProgress({ stage: "uploading", percent: 0 });
const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`; const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`;
const up = await uploadFieldFile({ // L7: the file goes into the local upload queue; the outbox uploads it before photo.attach
const up = await queueBlob({
workOrderId, workOrderId,
kind: "photo", kind: "photo",
clientId: newClientId(), blob: image,
file: image,
fileName: thumbnail ? name : file.name || name, fileName: thumbnail ? name : file.name || name,
preview: thumbnail, preview: thumbnail,
onProgress: (percent) => setProgress({ stage: "uploading", percent }), onProgress: (percent) => setProgress({ stage: "uploading", percent }),
}); });
if (!up.ok) { if (!up.ok) {
setProgress({ stage: "idle" }); setProgress({ stage: "idle" });
setError(t(`errors.${up.error}`)); setError(t(`errors.${["network", "invalid", "forbidden", "not_found"].includes(up.error) ? up.error : "internal"}`));
return false; return false;
} }
documentId = up.documentId; documentId = up.documentId;
@@ -87,7 +86,7 @@ function usePhotoSave(workOrderId: string) {
} }
uploaded.current = null; uploaded.current = null;
setSaved(true); setSaved(true);
router.refresh(); if (!isQueued(result)) router.refresh();
return true; return true;
} }
+5 -5
View File
@@ -4,8 +4,7 @@ import { useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react"; import { CircleCheck, LoaderCircle, Mic, Square, TriangleAlert, Upload } from "lucide-react";
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops"; import { errorKey, isQueued, isSuccess, newClientId, queueBlob, submitOp } from "@/lib/field/client-ops";
import { uploadFieldFile } from "@/lib/field/upload";
import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui"; import { btnPrimary, btnSecondary, noticeError, noticeOk } from "./ui";
export const MAX_RECORDING_SECONDS = 300; export const MAX_RECORDING_SECONDS = 300;
@@ -87,9 +86,10 @@ export function VoiceRecorder({ workOrderId }: { workOrderId: string }) {
setState("saving"); setState("saving");
setError(null); setError(null);
const type = clip.blob.type || "audio/webm"; const type = clip.blob.type || "audio/webm";
const up = await uploadFieldFile({ workOrderId, kind: "voice_note", clientId: newClientId(), file: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent }); // L7: queued locally, uploaded by the outbox before voice.attach
const up = await queueBlob({ workOrderId, kind: "voice_note", blob: clip.blob, fileName: `sprachnotiz.${extensionFor(type)}`, onProgress: setPercent });
if (!up.ok) { if (!up.ok) {
setError(t(`errors.${up.error}`)); setError(t(`errors.${["network", "invalid", "forbidden", "not_found"].includes(up.error) ? up.error : "internal"}`));
setState("recorded"); setState("recorded");
return; return;
} }
@@ -105,7 +105,7 @@ export function VoiceRecorder({ workOrderId }: { workOrderId: string }) {
setClip(null); setClip(null);
setSaved(true); setSaved(true);
setState("idle"); setState("idle");
router.refresh(); if (!isQueued(result)) router.refresh();
} }
if (!supported) return <p className="text-[14px] text-muted-foreground">{t("voice.unsupported")}</p>; if (!supported) return <p className="text-[14px] text-muted-foreground">{t("voice.unsupported")}</p>;
+22
View File
@@ -0,0 +1,22 @@
/** Formatting helpers of the offline UI (locale-aware, no texts). */
export function fmtBytes(bytes: number, locale: string): string {
const units = ["B", "KB", "MB", "GB"];
let v = bytes;
let i = 0;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: i === 0 ? 0 : 1 }).format(v)} ${units[i]}`;
}
export function fmtDateTimeShort(iso: string | null, locale: string): string {
if (!iso) return "";
return new Intl.DateTimeFormat(locale, { dateStyle: "short", timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(iso));
}
export function fmtTimeShort(iso: string | null, locale: string): string {
if (!iso) return "";
return new Intl.DateTimeFormat(locale, { timeStyle: "short", timeZone: "Europe/Berlin" }).format(new Date(iso));
}
+54
View File
@@ -0,0 +1,54 @@
"use client";
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
import { getOfflineState, getServerOfflineState, subscribeOffline, type OfflineState } from "@/lib/offline/outbox";
import { clearDraft, loadDraft, saveDraft } from "@/lib/offline/drafts";
/** Live outbox/sync state of the mobile shell. */
export function useOfflineState(): OfflineState {
return useSyncExternalStore(subscribeOffline, getOfflineState, getServerOfflineState);
}
/**
* Form state with automatic draft storage in IndexedDB (debounced 400 ms). Returns
* [value, setValue, clear, restored] — `restored` is true when a stored draft was loaded.
*/
export function useOfflineDraft<T>(key: string, initial: T): [T, (next: T) => void, () => Promise<void>, boolean] {
const [value, setValue] = useState<T>(initial);
const [restored, setRestored] = useState(false);
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
const touched = useRef(false);
useEffect(() => {
let cancelled = false;
touched.current = false;
loadDraft<T>(key).then((draft) => {
if (cancelled || draft === null || touched.current) return;
setValue(draft);
setRestored(true);
});
return () => {
cancelled = true;
};
}, [key]);
useEffect(() => () => (timer.current ? clearTimeout(timer.current) : undefined), []);
const update = useCallback(
(next: T) => {
touched.current = true;
setValue(next);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => void saveDraft(key, next), 400);
},
[key],
);
const clear = useCallback(async () => {
if (timer.current) clearTimeout(timer.current);
setRestored(false);
await clearDraft(key);
}, [key]);
return [value, update, clear, restored];
}
+72
View File
@@ -0,0 +1,72 @@
"use client";
import { useEffect, useState, useSyncExternalStore } from "react";
import { useTranslations } from "next-intl";
import { CircleCheck, Download, Smartphone } from "lucide-react";
import { btnSecondary, card } from "@/components/field/ui";
type Mode = "unknown" | "installed" | "ios" | "other";
type InstallPromptEvent = Event & { prompt: () => Promise<void>; userChoice: Promise<{ outcome: "accepted" | "dismissed" }> };
function detect(): Mode {
const standalone = window.matchMedia("(display-mode: standalone)").matches || (navigator as Navigator & { standalone?: boolean }).standalone === true;
if (standalone) return "installed";
const ios = /iPad|iPhone|iPod/.test(navigator.userAgent) || (navigator.platform === "MacIntel" && navigator.maxTouchPoints > 1);
return ios ? "ios" : "other";
}
const noSubscribe = () => () => undefined;
/** Installation hint in the profile (lane L7, Spec §3.2): native prompt where available, iOS instructions otherwise. */
export function InstallHint() {
const t = useTranslations("offline.install");
const mode = useSyncExternalStore<Mode>(noSubscribe, detect, () => "unknown");
const [prompt, setPrompt] = useState<InstallPromptEvent | null>(null);
const [installed, setInstalled] = useState(false);
useEffect(() => {
const onPrompt = (e: Event) => {
e.preventDefault();
setPrompt(e as InstallPromptEvent);
};
const onInstalled = () => setInstalled(true);
window.addEventListener("beforeinstallprompt", onPrompt);
window.addEventListener("appinstalled", onInstalled);
return () => {
window.removeEventListener("beforeinstallprompt", onPrompt);
window.removeEventListener("appinstalled", onInstalled);
};
}, []);
if (mode === "unknown") return null;
return (
<section className={`${card} space-y-2`}>
<h2 className="flex items-center gap-2 text-[17px]">
<Smartphone className="size-5 text-muted-foreground" aria-hidden />
{t("title")}
</h2>
{mode === "installed" || installed ? (
<p className="flex items-center gap-2 text-[15px]">
<CircleCheck className="size-4.5 text-[var(--ok)]" aria-hidden />
{t("installed")}
</p>
) : prompt ? (
<button
type="button"
className={btnSecondary}
onClick={async () => {
await prompt.prompt();
const choice = await prompt.userChoice.catch(() => null);
if (choice?.outcome === "accepted") setInstalled(true);
setPrompt(null);
}}
>
<Download className="size-5" aria-hidden />
{t("button")}
</button>
) : (
<p className="text-[15px]">{mode === "ios" ? t("ios") : t("other")}</p>
)}
</section>
);
}
+79
View File
@@ -0,0 +1,79 @@
"use client";
import { useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { LoaderCircle, LogOut, RefreshCw, TriangleAlert } from "lucide-react";
import { btnSecondary, noticeError } from "@/components/field/ui";
import { clearLocalData, syncNow, unsentCount } from "@/lib/offline/outbox";
/**
* Sign-out of the mobile app (lane L7, Spec §23): local data of this tenant/user is deleted before
* the session ends. If ops/uploads are still unsent, the user is warned first and can sync.
*/
export function LogoutForm({ action, label }: { action: () => Promise<void>; label: string }) {
const t = useTranslations("offline.logout");
const form = useRef<HTMLFormElement>(null);
const proceed = useRef(false);
const [unsent, setUnsent] = useState(0);
const [busy, setBusy] = useState(false);
async function finish() {
setBusy(true);
await clearLocalData().catch(() => undefined);
proceed.current = true;
form.current?.requestSubmit();
}
return (
<form
ref={form}
action={action}
className="space-y-2"
onSubmit={async (e) => {
if (proceed.current) return;
e.preventDefault();
const n = await unsentCount().catch(() => 0);
if (n > 0) {
setUnsent(n);
return;
}
await finish();
}}
>
{unsent > 0 ? (
<div className="space-y-2" role="alertdialog" aria-label={t("warning", { count: unsent })}>
<p className={noticeError}>
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("warning", { count: unsent })}
</p>
<button
type="button"
className={btnSecondary}
disabled={busy}
onClick={async () => {
setBusy(true);
await syncNow().catch(() => undefined);
setUnsent(await unsentCount().catch(() => 0));
setBusy(false);
}}
>
<RefreshCw className="size-5" aria-hidden />
{t("syncFirst")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => void finish()}>
<LogOut className="size-5" aria-hidden />
{t("confirm")}
</button>
<button type="button" className={btnSecondary} disabled={busy} onClick={() => setUnsent(0)}>
{t("cancel")}
</button>
</div>
) : (
<button type="submit" className={btnSecondary} disabled={busy}>
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <LogOut className="size-5" aria-hidden />}
{label}
</button>
)}
</form>
);
}
@@ -0,0 +1,94 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslations } from "next-intl";
import { RefreshCw } from "lucide-react";
import { configureOffline, startSyncLoop } from "@/lib/offline/outbox";
import { SyncBadge } from "./sync-badge";
const SW_URL = "/sw.js";
const SW_DEV_FLAG = "craftvia.sw";
const UPDATE_CHECK_MS = 30 * 60_000;
/** In development the SW is opt-in (localStorage craftvia.sw = "1"), otherwise HMR chunks would be cached. */
function swEnabled(): boolean {
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return false;
if (process.env.NODE_ENV === "production") return true;
try {
return localStorage.getItem(SW_DEV_FLAG) === "1";
} catch {
return false;
}
}
export function OfflineRuntimeClient({ tenantId, userId, maxDays }: { tenantId: string; userId: string; maxDays: number }) {
const t = useTranslations("offline.update");
const [waiting, setWaiting] = useState<ServiceWorker | null>(null);
const reloading = useRef(false);
// outbox context + sync loop
useEffect(() => {
let stop: (() => void) | undefined;
let cancelled = false;
configureOffline({ tenantId, userId }, { maxDays }).then(() => {
if (!cancelled) stop = startSyncLoop();
});
return () => {
cancelled = true;
stop?.();
};
}, [tenantId, userId, maxDays]);
// service worker registration + update flow
useEffect(() => {
if (!swEnabled()) return;
let timer: ReturnType<typeof setInterval> | undefined;
const onControllerChange = () => {
if (!reloading.current) return;
reloading.current = false;
window.location.reload();
};
navigator.serviceWorker.addEventListener("controllerchange", onControllerChange);
navigator.serviceWorker
.register(SW_URL, { scope: "/", updateViaCache: "none" })
.then((reg) => {
if (reg.waiting && navigator.serviceWorker.controller) setWaiting(reg.waiting);
reg.addEventListener("updatefound", () => {
const installing = reg.installing;
installing?.addEventListener("statechange", () => {
if (installing.state === "installed" && navigator.serviceWorker.controller) setWaiting(installing);
});
});
// refresh the cached offline page with the current build and the signed-in session
(reg.active ?? reg.installing ?? reg.waiting)?.postMessage({ type: "PRECACHE_OFFLINE" });
timer = setInterval(() => void reg.update().catch(() => undefined), UPDATE_CHECK_MS);
})
.catch(() => undefined);
return () => {
navigator.serviceWorker.removeEventListener("controllerchange", onControllerChange);
if (timer) clearInterval(timer);
};
}, []);
return (
<>
<SyncBadge />
{waiting && (
<div role="status" aria-live="polite" className="fixed inset-x-3 bottom-[calc(5.5rem+env(safe-area-inset-bottom))] z-40 mx-auto flex max-w-xl items-center gap-3 rounded-xl border bg-card p-3 shadow-card">
<RefreshCw className="size-5 shrink-0 text-primary" aria-hidden />
<p className="flex-1 text-[14px] font-semibold">{t("available")}</p>
<button
type="button"
className="inline-flex min-h-12 items-center rounded-xl bg-cta px-4 text-[15px] font-semibold text-cta-foreground"
onClick={() => {
reloading.current = true;
waiting.postMessage({ type: "SKIP_WAITING" });
}}
>
{t("reload")}
</button>
</div>
)}
</>
);
}
@@ -0,0 +1,16 @@
import { auth } from "@/server/auth";
import { parseMaxDays } from "@/lib/offline/bundle-core";
import { OfflineRuntimeClient } from "./offline-runtime-client";
/**
* Offline runtime of the mobile shell (lane L7) — mounted once in `(field)/m/layout.tsx`.
* Passes the signed-in tenant/user (local data is separated per context) and OFFLINE_MAX_DAYS
* to the client: service worker registration + update notice, outbox sync loop, sync badge.
*/
export async function OfflineRuntime() {
const session = await auth();
const tenantId = session?.user?.tenantId;
const userId = session?.user?.id;
if (!tenantId || !userId) return null;
return <OfflineRuntimeClient tenantId={tenantId} userId={userId} maxDays={parseMaxDays(process.env.OFFLINE_MAX_DAYS)} />;
}
+590
View File
@@ -0,0 +1,590 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { Camera, Check, ChevronLeft, ChevronRight, CircleCheck, Clock, FileText, History, LoaderCircle, MapPin, Package, Phone, StickyNote, TriangleAlert, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { fmtWindow } from "@/lib/field/format";
import { compressImage } from "@/lib/field/image";
import { errorKey, isSuccess, newClientId, submitOp } from "@/lib/field/client-ops";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import { NOTE_KINDS, type NoteKind } from "@/lib/sync/ops";
import { isQueued, queueBlob } from "@/lib/offline/outbox";
import { readOrders, type OrdersRead } from "@/lib/offline/read";
import { cachedDocumentIds } from "@/lib/offline/prefetch";
import { docUrl } from "@/lib/offline/doc-cache";
import type { OrderView } from "@/lib/offline/bundle-core";
import type { SyncOpResult } from "@/lib/sync/envelope";
import { useOnline } from "@/components/field/online-badge";
import { resolveActions } from "@/components/field/primary-action";
import { StatusBadge } from "@/components/field/status-badge";
import { btnPrimary, btnSecondary, card, chip, inputClass, noticeError, noticeOk, noticeWarn, toneClasses } from "@/components/field/ui";
import { useOfflineDraft, useOfflineState } from "./hooks";
import { fmtDateTimeShort } from "./format";
/**
* `/m/offline` (lane L7, Spec §23): order list and detail rendered from the local bundle
* (IndexedDB) incl. own unsent changes. The service worker redirects offline navigations of
* `/m`, `/m/orders` and `/m/orders/<id>` here (`?from=`). Navigation inside the view is local
* (no server round trip).
*/
function initialOrderId(): string | null {
if (typeof window === "undefined") return null;
const from = new URLSearchParams(window.location.search).get("from") ?? "";
const m = from.match(/^\/m\/orders\/([^/?#]+)/);
return m ? decodeURIComponent(m[1]) : null;
}
export function OfflineView() {
const t = useTranslations("offline.view");
const tSync = useTranslations("offline.sync");
const locale = useLocale();
const online = useOnline();
const s = useOfflineState();
const [orderId, setOrderId] = useState<string | null>(initialOrderId);
const [data, setData] = useState<OrdersRead | null>(null);
const [tick, setTick] = useState(0);
const reload = useCallback(() => setTick((n) => n + 1), []);
useEffect(() => {
let cancelled = false;
readOrders().then((r) => {
if (!cancelled) setData(r);
});
return () => {
cancelled = true;
};
}, [tick, s.ready, s.lastPullAt, s.summary]);
const open = (id: string | null) => {
setOrderId(id);
const from = id ? `?from=${encodeURIComponent(`/m/orders/${id}`)}` : "";
window.history.replaceState(null, "", `/m/offline${from}`);
window.scrollTo(0, 0);
};
if (!data) {
return (
<p className="flex items-center gap-2 p-4 text-[15px] text-muted-foreground" role="status">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{t("loading")}
</p>
);
}
const order = orderId ? data.orders.find((o) => o.id === orderId) ?? null : null;
return (
<div className="space-y-4">
{online ? (
<div className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
<span>
{t("onlineHint")}{" "}
<a href={orderId ? `/m/orders/${encodeURIComponent(orderId)}` : "/m"} className="font-semibold underline">
{t("backOnline")}
</a>
</span>
</div>
) : (
<p className={noticeWarn} role="status">
<WifiOff className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("offlineHint")}
</p>
)}
{data.syncedAt && <p className="text-[13px] text-muted-foreground">{t("savedAt", { time: fmtDateTimeShort(data.syncedAt, locale) })}</p>}
{data.stale && data.orders.length > 0 && (
<p className={noticeWarn} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
{tSync("stale", { days: s.maxDays })}
</p>
)}
{orderId ? (
<>
<button type="button" onClick={() => open(null)} className="-ml-2 inline-flex min-h-12 items-center gap-1 rounded-xl px-2 text-[15px] font-semibold text-primary">
<ChevronLeft className="size-5" aria-hidden />
{t("backToList")}
</button>
{order ? <OrderDetail order={order} onChanged={reload} /> : <p className={cn(card, "text-[15px]")}>{t("notFound")}</p>}
</>
) : !data.ready ? (
<p className={cn(card, "text-[15px] text-muted-foreground")}>{t("notReady")}</p>
) : data.orders.length === 0 ? (
<p className={cn(card, "text-[15px] text-muted-foreground")}>{t("empty")}</p>
) : (
<>
<p className="text-[15px] font-semibold">{t("count", { count: data.orders.length })}</p>
<ul className="space-y-3">
{data.orders.map((o) => (
<li key={o.id}>
<OrderListCard order={o} locale={locale} onOpen={() => open(o.id)} />
</li>
))}
</ul>
</>
)}
</div>
);
}
const customerName = (c: OrderView["customer"]) => c.companyName?.trim() || [c.firstName, c.lastName].filter(Boolean).join(" ") || "—";
const address = (a: { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null } | null | undefined) =>
a ? [[a.street, a.houseNumber].filter(Boolean).join(" "), [a.postalCode, a.city].filter(Boolean).join(" ")].filter(Boolean).join(", ") || null : null;
const dateOrNull = (iso: string | null) => (iso ? new Date(iso) : null);
function LocalNotices({ order }: { order: OrderView }) {
const t = useTranslations("offline.view");
return (
<>
{order.local.pendingOps > 0 && (
<p className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-[var(--info)]">
<Clock className="size-4" aria-hidden />
{t("pending", { count: order.local.pendingOps })}
</p>
)}
{(order.local.conflict || order.local.rejected) && (
<Link href="/m/sync" className="flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-foreground underline">
<TriangleAlert className="size-4 text-[var(--warn)]" aria-hidden />
{order.local.conflict ? t("conflict") : t("rejected")}
</Link>
)}
</>
);
}
function OrderListCard({ order, locale, onOpen }: { order: OrderView; locale: string; onOpen: () => void }) {
const t = useTranslations("offline.view");
const window = fmtWindow(dateOrNull(order.plannedStart), dateOrNull(order.plannedEnd), locale);
const addr = address(order.site) ?? address(order.customer);
return (
<article className={cn("rounded-xl border border-l-4 bg-card p-4 shadow-card", toneClasses(order.statusGroup as Parameters<typeof toneClasses>[0])?.edge)}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
<StatusBadge status={order.status as WorkOrderStatus} />
</div>
<h2 className="mt-2 text-[18px] leading-snug">{order.title}</h2>
<p className="mt-0.5 text-[15px] font-semibold">{customerName(order.customer)}</p>
<dl className="mt-2 space-y-1.5 text-[15px]">
<div className="flex items-start gap-2">
<Clock className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
<dd>{window ?? t("noDate")}</dd>
</div>
{addr && (
<div className="flex items-start gap-2">
<MapPin className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
<dd>{addr}</dd>
</div>
)}
</dl>
<div className="mt-2 space-y-1">
<LocalNotices order={order} />
</div>
<button type="button" onClick={onOpen} className={cn(btnPrimary, "mt-3")}>
{t("open")}
<ChevronRight className="size-5" aria-hidden />
</button>
</article>
);
}
type Feedback = { kind: "queued" | "saved" | "error"; text: string } | null;
function useFeedback() {
const t = useTranslations("offline.view");
const tf = useTranslations("field");
const [feedback, setFeedback] = useState<Feedback>(null);
const report = (result: SyncOpResult) => {
if (isQueued(result)) setFeedback({ kind: "queued", text: t("queued") });
else if (isSuccess(result)) setFeedback({ kind: "saved", text: t("saved") });
else setFeedback({ kind: "error", text: tf(`errors.${errorKey(result)}`) });
};
return { feedback, setFeedback, report };
}
function FeedbackNotice({ feedback }: { feedback: Feedback }) {
if (!feedback) return null;
const Icon = feedback.kind === "error" ? TriangleAlert : CircleCheck;
return (
<p className={feedback.kind === "error" ? noticeError : noticeOk} role={feedback.kind === "error" ? "alert" : "status"}>
<Icon className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{feedback.text}
</p>
);
}
function Section({ title, icon: Icon, children }: { title: string; icon: typeof Clock; children: React.ReactNode }) {
return (
<section className={cn(card, "space-y-2")}>
<h2 className="flex min-h-10 items-center gap-2.5 text-[17px]">
<Icon className="size-5 shrink-0 text-muted-foreground" aria-hidden />
{title}
</h2>
<div className="space-y-2 text-[15px]">{children}</div>
</section>
);
}
function OrderDetail({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
const tf = useTranslations("field");
const locale = useLocale();
const [cached, setCached] = useState<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
cachedDocumentIds().then((ids) => {
if (!cancelled) setCached(ids);
});
return () => {
cancelled = true;
};
}, [order.id]);
const siteAddress = address(order.site) ?? address(order.customer);
const hints: Array<[string, string | null | undefined]> = [
[t("access"), order.site?.accessNotes],
[t("parking"), order.site?.parkingNotes],
[t("safety"), order.site?.safetyNotes],
[t("technical"), order.site?.technicalNotes],
];
const contact = order.site?.contact ?? order.contact;
const phone = contact?.mobile || contact?.phone || order.customer.mobile || order.customer.phone;
const recorded = order.materialPlans.filter((p) => order.materialUsages.some((u) => u.materialPlanId === p.id)).length;
return (
<div className="space-y-4">
<header className={cn(card, "space-y-2")}>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-[13px] font-semibold text-muted-foreground">{order.number}</span>
<StatusBadge status={order.status as WorkOrderStatus} />
</div>
<h1 className="text-[22px] leading-snug">{order.title}</h1>
<p className="text-[15px]">{fmtWindow(dateOrNull(order.plannedStart), dateOrNull(order.plannedEnd), locale) ?? t("noDate")}</p>
<LocalNotices order={order} />
<TimeActions order={order} onChanged={onChanged} />
</header>
{order.technicianNotes && (
<Section title={t("hints")} icon={TriangleAlert}>
<p className="whitespace-pre-line">{order.technicianNotes}</p>
</Section>
)}
<Section title={t("site")} icon={MapPin}>
{order.site?.name && <p className="font-semibold">{order.site.name}</p>}
{siteAddress && <p>{siteAddress}</p>}
<dl className="space-y-1.5">
{hints
.filter(([, v]) => !!v)
.map(([label, value]) => (
<div key={label} className="rounded-lg bg-muted px-3 py-2">
<dt className="text-[13px] font-semibold">{label}</dt>
<dd className="whitespace-pre-line">{value}</dd>
</div>
))}
</dl>
</Section>
<Section title={t("customer")} icon={Phone}>
<p className="font-semibold">{customerName(order.customer)}</p>
{contact && (
<p>
{t("contact")}: {contact.name}
{contact.role ? ` (${contact.role})` : ""}
</p>
)}
{phone && (
<a href={`tel:${phone.replace(/[^\d+]/g, "")}`} className={btnSecondary}>
<Phone className="size-5" aria-hidden />
{t("call")}
</a>
)}
</Section>
{(order.scope || order.description) && (
<Section title={order.scope ? t("scope") : t("description")} icon={FileText}>
{order.scope && <p className="whitespace-pre-line">{order.scope}</p>}
{order.description && <p className="whitespace-pre-line text-muted-foreground">{order.description}</p>}
</Section>
)}
<Section title={t("checklist")} icon={Check}>
{order.checklistItems.length === 0 ? <p className="text-muted-foreground">{t("checklistEmpty")}</p> : <Checklist order={order} onChanged={onChanged} />}
</Section>
<Section title={t("photos")} icon={Camera}>
<PhotoQuick order={order} onChanged={onChanged} />
{order.photoRequirements.length > 0 && (
<ul className="divide-y">
{order.photoRequirements.map((r) => (
<li key={r.id} className="flex min-h-11 items-center justify-between gap-2">
<span>{r.label}</span>
<span className={cn("text-[13px] font-semibold", r._count.photos > 0 ? "text-[var(--ok)]" : "text-muted-foreground")}>{r._count.photos > 0 ? t("photoDone") : t("photoMissing")}</span>
</li>
))}
</ul>
)}
{order.local.photos.length > 0 && (
<ul className="space-y-1">
{order.local.photos.map((p) => (
<li key={p.id} className="flex items-center gap-2 text-[14px]">
<Camera className="size-4 text-muted-foreground" aria-hidden />
{fmtDateTimeShort(p.createdAt, locale)} {p.phase ? `· ${tf(`photos.phase.${p.phase}`)}` : ""} {p.pending && <span className="font-semibold text-[var(--info)]">· {t("pendingBadge")}</span>}
</li>
))}
</ul>
)}
</Section>
<Section title={t("notes")} icon={StickyNote}>
<NoteQuick order={order} onChanged={onChanged} />
{order.local.notes.map((n) => (
<div key={n.id} className="rounded-lg bg-muted px-3 py-2">
<p className="text-[13px] font-semibold">
{tf(`notes.kind.${n.kind}`)} · {fmtDateTimeShort(n.createdAt, locale)} {n.pending && <span className="text-[var(--info)]">· {t("pendingBadge")}</span>}
</p>
<p className="whitespace-pre-line">{n.text}</p>
</div>
))}
</Section>
{order.materialPlans.length > 0 && (
<Section title={t("materials")} icon={Package}>
<p>{t("materialsProgress", { done: recorded, total: order.materialPlans.length })}</p>
</Section>
)}
<Section title={t("documents")} icon={FileText}>
{order.documents.length === 0 ? (
<p className="text-muted-foreground">{t("noDocuments")}</p>
) : (
<ul className="divide-y">
{order.documents.map((d) => (
<li key={d.id}>
<a href={docUrl(d.id)} target="_blank" rel="noopener" className="flex min-h-12 items-center gap-2 py-1.5">
<FileText className="size-5 shrink-0 text-muted-foreground" aria-hidden />
<span className="flex-1">
<span className="block font-semibold">{d.title || d.fileName}</span>
<span className="text-[13px] text-muted-foreground">{cached.has(d.id) ? t("docOffline") : t("docOnline")}</span>
</span>
</a>
</li>
))}
</ul>
)}
</Section>
<Section title={t("history")} icon={History}>
{order.siteHistory.length === 0 ? (
<p className="text-muted-foreground">{t("noHistory")}</p>
) : (
<ul className="divide-y">
{order.siteHistory.map((h) => (
<li key={h.reportId} className="py-2">
<p className="font-semibold">
{h.workOrderNumber} · {tf(`detail.reportType.${h.reportType}`)}
</p>
<p className="text-[13px] text-muted-foreground">
{fmtDateTimeShort(h.reportDate, locale)} · {h.workOrderTitle}
</p>
</li>
))}
</ul>
)}
</Section>
</div>
);
}
type Action = "accept" | "travel" | "start" | "pause" | "resume" | "end";
function TimeActions({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
const { feedback, report } = useFeedback();
const [busy, setBusy] = useState<Action | null>(null);
const resolved = resolveActions(order.status as WorkOrderStatus, order.local.session);
const actions = [resolved.primary, resolved.secondary]
.map((a) => (a === "complete" ? (order.local.session === "running" || order.local.session === "paused" ? "end" : null) : a))
.filter((a, i, arr): a is Action => !!a && arr.indexOf(a) === i);
const showCompleteHint = resolved.primary === "complete" || resolved.secondary === "complete";
const labels: Record<Action, string> = { accept: t("actionAccept"), travel: t("actionTravel"), start: t("actionStart"), pause: t("actionPause"), resume: t("actionResume"), end: t("actionEnd") };
async function run(action: Action) {
setBusy(action);
const at = new Date().toISOString();
const workOrderId = order.id;
const offline = !navigator.onLine;
const startPayload = (mode: "travel" | "work") => ({ workOrderId, mode, clientId: newClientId(), at, offline, deviceInfo: navigator.userAgent.slice(0, 200) });
const result =
action === "accept"
? await submitOp({ opType: "work_order.transition", baseVersion: order.version, payload: { workOrderId, to: "accepted" } })
: action === "travel" || action === "start"
? await submitOp({ opType: "session.start", payload: startPayload(action === "travel" ? "travel" : "work") })
: await submitOp({ opType: action === "pause" ? "session.pause" : action === "resume" ? "session.resume" : "session.end", payload: { workOrderId, at } });
setBusy(null);
report(result);
onChanged();
}
if (actions.length === 0 && !showCompleteHint) return null;
return (
<div className="space-y-2.5 pt-1">
<h2 className="sr-only">{t("time")}</h2>
{actions.map((a, i) => (
<button key={a} type="button" className={i === 0 ? btnPrimary : btnSecondary} disabled={busy !== null} onClick={() => run(a)}>
{busy === a ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Clock className="size-5" aria-hidden />}
{busy === a ? t("saving") : labels[a]}
</button>
))}
{showCompleteHint && <p className="text-[14px] text-muted-foreground">{t("completeOnline")}</p>}
<FeedbackNotice feedback={feedback} />
</div>
);
}
function Checklist({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
const { feedback, report } = useFeedback();
const [busy, setBusy] = useState<string | null>(null);
return (
<>
<ul className="space-y-2">
{order.checklistItems.map((item) => (
<li key={item.id}>
<button
type="button"
role="checkbox"
aria-checked={item.checked}
disabled={busy !== null}
className="flex min-h-14 w-full items-center gap-3 text-left"
onClick={async () => {
setBusy(item.id);
const result = await submitOp({ opType: "checklist.toggle", payload: { workOrderId: order.id, itemId: item.id, checked: !item.checked } });
setBusy(null);
report(result);
onChanged();
}}
>
<span className={cn("grid size-9 shrink-0 place-items-center rounded-lg border-2", item.checked ? "border-[var(--ok)] bg-[var(--ok)] text-white" : "border-input bg-card")}>
{busy === item.id ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : item.checked && <Check className="size-6" aria-hidden />}
</span>
<span className="flex-1">
<span className="block font-semibold leading-snug">{item.label}</span>
<span className="text-[12.5px] font-semibold text-muted-foreground">
{item.checked ? t("done") : t("open_item")}
{item.required ? ` · ${t("required")}` : ""}
</span>
</span>
</button>
</li>
))}
</ul>
<FeedbackNotice feedback={feedback} />
</>
);
}
function NoteQuick({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
const tf = useTranslations("field");
const { feedback, report, setFeedback } = useFeedback();
const [draft, setDraft, clearDraft, restored] = useOfflineDraft<{ kind: NoteKind; text: string; clientId: string }>(`note:${order.id}`, { kind: "work_done", text: "", clientId: "" });
const [busy, setBusy] = useState(false);
return (
<form
className="space-y-3"
onSubmit={async (e) => {
e.preventDefault();
if (!draft.text.trim()) return;
setBusy(true);
const clientId = draft.clientId || newClientId();
const result = await submitOp({ opType: "note.create", payload: { workOrderId: order.id, clientId, kind: draft.kind, text: draft.text.trim() } });
setBusy(false);
report(result);
if (isQueued(result) || isSuccess(result)) {
setDraft({ kind: draft.kind, text: "", clientId: "" });
await clearDraft();
onChanged();
}
}}
>
<div className="grid grid-cols-2 gap-2">
{NOTE_KINDS.slice(0, 4).map((k) => (
<button key={k} type="button" aria-pressed={draft.kind === k} className={chip(draft.kind === k)} onClick={() => setDraft({ ...draft, kind: k, clientId: draft.clientId || newClientId() })}>
{tf(`notes.kind.${k}`)}
</button>
))}
</div>
<textarea
rows={3}
maxLength={10000}
aria-label={t("notes")}
className={cn(inputClass, "py-3")}
placeholder={t("notePlaceholder")}
value={draft.text}
onChange={(e) => {
setFeedback(null);
setDraft({ ...draft, text: e.target.value, clientId: draft.clientId || newClientId() });
}}
/>
{restored && draft.text && <p className="text-[13px] text-muted-foreground">{t("draftRestored")}</p>}
<FeedbackNotice feedback={feedback} />
<button type="submit" className={btnSecondary} disabled={busy || !draft.text.trim()}>
{busy ? t("saving") : t("noteSave")}
</button>
</form>
);
}
function PhotoQuick({ order, onChanged }: { order: OrderView; onChanged: () => void }) {
const t = useTranslations("offline.view");
const tf = useTranslations("field");
const { feedback, report, setFeedback } = useFeedback();
const [busy, setBusy] = useState(false);
return (
<>
<label className={cn(btnPrimary, "cursor-pointer", busy && "opacity-60")}>
{busy ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <Camera className="size-5" aria-hidden />}
{busy ? t("saving") : t("photo")}
<input
type="file"
accept="image/*"
capture="environment"
className="sr-only"
disabled={busy}
onChange={async (e) => {
const file = e.target.files?.[0];
e.target.value = "";
if (!file) return;
setBusy(true);
setFeedback(null);
try {
const { image, thumbnail } = await compressImage(file);
const name = `${(file.name || "foto").replace(/\.[^.]+$/, "")}.jpg`;
const queued = await queueBlob({ workOrderId: order.id, kind: "photo", blob: image, preview: thumbnail, fileName: name });
if (!queued.ok) {
setFeedback({ kind: "error", text: tf("errors.internal") });
return;
}
const result = await submitOp({
opType: "photo.attach",
payload: { workOrderId: order.id, clientId: newClientId(), documentId: queued.documentId, phase: "during", takenAt: new Date(Math.min(file.lastModified || Date.now(), Date.now())).toISOString() },
});
report(result);
onChanged();
} catch {
setFeedback({ kind: "error", text: tf("errors.image") });
} finally {
setBusy(false);
}
}}
/>
</label>
<FeedbackNotice feedback={feedback} />
</>
);
}
+39
View File
@@ -0,0 +1,39 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { CircleCheck, RefreshCw, TriangleAlert } from "lucide-react";
import { cn } from "@/lib/utils";
import { useOfflineState } from "./hooks";
/**
* Sync badge of the mobile header (Spec §22 "Synchronisationsstatus sichtbar"): number of pending
* changes/uploads, warning symbol on errors/conflicts/expired session. Text + icon, never colour only.
*/
export function SyncBadge() {
const t = useTranslations("offline.badge");
const s = useOfflineState();
if (!s.ready) return null;
const pending = s.summary.pendingOps + s.summary.pendingUploads;
const problem = s.summary.conflicts + s.summary.rejected + s.summary.failedUploads > 0 || s.summary.waitingForAuth;
const Icon = problem ? TriangleAlert : pending > 0 || s.syncing ? RefreshCw : CircleCheck;
const label = problem ? t("problem") : pending > 0 ? t("pending", { count: pending }) : s.syncing ? t("syncing") : t("synced");
return (
<Link
href="/m/sync"
aria-label={`${t("label")}: ${label}`}
className={cn(
"inline-flex min-h-11 items-center gap-1.5 rounded-full px-2.5 text-[12.5px] font-semibold",
problem
? "bg-[color-mix(in_oklch,var(--warn)_16%,transparent)] text-foreground"
: pending > 0
? "bg-[color-mix(in_oklch,var(--info)_12%,transparent)] text-[var(--info)]"
: "bg-muted text-muted-foreground",
)}
>
<Icon className={cn("size-4", problem && "text-[var(--warn)]", s.syncing && !problem && "animate-spin")} aria-hidden />
{label}
</Link>
);
}
+287
View File
@@ -0,0 +1,287 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useLocale, useTranslations } from "next-intl";
import { CircleCheck, CloudDownload, HardDrive, LoaderCircle, RefreshCw, RotateCcw, Trash2, TriangleAlert, WifiOff } from "lucide-react";
import { cn } from "@/lib/utils";
import { OnlineBadge } from "@/components/field/online-badge";
import { btnPrimary, btnSecondary, card, noticeError, noticeOk, noticeWarn } from "@/components/field/ui";
import { acknowledgeConflict, clearLocalData, discardOp, listOutbox, retryOp, syncNow, unsentCount, type OutboxListing } from "@/lib/offline/outbox";
import { canDiscard, isPending, problemKey } from "@/lib/offline/outbox-core";
import { isBundleStale } from "@/lib/offline/bundle-core";
import { readOrders } from "@/lib/offline/read";
import type { OutboxEntry } from "@/lib/offline/types";
import { useOfflineState } from "./hooks";
import { fmtBytes, fmtDateTimeShort, fmtTimeShort } from "./format";
/** `/m/sync` (Spec §22/§23.4, US-012): connection, last sync, pending ops/uploads, errors & conflicts, storage, reset. */
export function SyncPanel() {
const t = useTranslations("offline");
const locale = useLocale();
const s = useOfflineState();
const [listing, setListing] = useState<OutboxListing>({ ops: [], blobs: [] });
const [offlineOrders, setOfflineOrders] = useState<number | null>(null);
const [stale, setStale] = useState(false);
const [confirmDiscard, setConfirmDiscard] = useState<string | null>(null);
const [reset, setReset] = useState<{ unsent: number } | null>(null);
const [resetDone, setResetDone] = useState(false);
useEffect(() => {
if (!s.ready) return;
let cancelled = false;
Promise.all([listOutbox(), readOrders()]).then(([l, r]) => {
if (cancelled) return;
setListing(l);
setOfflineOrders(r.orders.length);
setStale(r.orders.length > 0 && isBundleStale(s.lastPullAt, new Date(), s.maxDays));
});
return () => {
cancelled = true;
};
}, [s.ready, s.summary, s.syncing, s.lastPullAt, s.maxDays]);
if (!s.ready) {
return (
<p className="flex items-center gap-2 text-[15px] text-muted-foreground" role="status">
<LoaderCircle className="size-4.5 animate-spin" aria-hidden />
{t("view.loading")}
</p>
);
}
const problems = listing.ops.filter((o) => (o.status === "conflict" || o.status === "rejected") && !o.acknowledged);
const queue = listing.ops.filter(isPending);
const uploads = Object.entries(s.uploads);
const nothingOpen = s.summary.pendingOps === 0 && s.summary.pendingUploads === 0;
return (
<div className="space-y-4">
<section className={cn(card, "space-y-3")} aria-labelledby="sync-connection">
<h2 id="sync-connection" className="text-[13px] font-semibold text-muted-foreground">
{t("sync.connection")}
</h2>
<OnlineBadge large />
{!s.online && (
<p className="flex items-start gap-2 text-[15px]">
<WifiOff className="mt-0.5 size-4.5 shrink-0 text-muted-foreground" aria-hidden />
{t("sync.offlineHint")}
</p>
)}
<dl className="grid grid-cols-1 gap-2 text-[15px] sm:grid-cols-2">
<div>
<dt className="text-[13px] text-muted-foreground">{t("sync.lastSync")}</dt>
<dd className="font-semibold">{s.lastSyncAt ? fmtDateTimeShort(s.lastSyncAt, locale) : t("sync.never")}</dd>
</div>
<div>
<dt className="text-[13px] text-muted-foreground">{t("sync.lastPull")}</dt>
<dd className="font-semibold">{s.lastPullAt ? fmtDateTimeShort(s.lastPullAt, locale) : t("sync.never")}</dd>
</div>
</dl>
{stale && (
<p className={noticeWarn} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
{t("sync.stale", { days: s.maxDays })}
</p>
)}
{s.summary.waitingForAuth && (
<div className={noticeWarn} role="alert">
<TriangleAlert className="mt-0.5 size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
<div className="space-y-2">
<p>{t("sync.waitingForAuth")}</p>
<a href="/login?callbackUrl=/m/sync" className="font-semibold underline">
{t("sync.login")}
</a>
</div>
</div>
)}
</section>
<section className={cn(card, "space-y-3")} aria-labelledby="sync-pending">
<h2 id="sync-pending" className="text-[17px]">
{t("sync.queue")}
</h2>
<p className="text-[15px] font-semibold">{t("sync.pendingOps", { count: s.summary.pendingOps })}</p>
<p className="text-[15px]">
{t("sync.pendingUploads", { count: s.summary.pendingUploads })}
{s.summary.uploadBytes > 0 && <span className="text-muted-foreground"> · {t("sync.uploadSize", { size: fmtBytes(s.summary.uploadBytes, locale) })}</span>}
</p>
{uploads.map(([id, percent]) => (
<div key={id} role="status" aria-live="polite" className="space-y-1.5">
<p className="text-[14px] font-semibold">{t("sync.uploading", { percent })}</p>
<div className="h-2.5 overflow-hidden rounded-full bg-muted">
<div className="h-full rounded-full bg-primary transition-[width]" style={{ width: `${percent}%` }} />
</div>
</div>
))}
{nothingOpen && !s.syncing && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("sync.allSynced")}
</p>
)}
{queue.length > 0 && (
<ul className="divide-y">
{queue.map((op) => (
<QueueRow key={op.clientOpId} op={op} locale={locale} />
))}
</ul>
)}
<button type="button" className={btnPrimary} disabled={s.syncing || !s.online} onClick={() => void syncNow({ pull: true })}>
{s.syncing ? <LoaderCircle className="size-5 animate-spin" aria-hidden /> : <RefreshCw className="size-5" aria-hidden />}
{s.syncing ? t("sync.syncing") : t("sync.syncNow")}
</button>
</section>
<section className={cn(card, "space-y-3")} aria-labelledby="sync-problems">
<h2 id="sync-problems" className="text-[17px]">
{t("sync.problems")}
</h2>
{problems.length === 0 ? (
<p className="text-[15px] text-muted-foreground">{t("sync.noProblems")}</p>
) : (
<ul className="space-y-3">
{problems.map((op) => (
<li key={op.clientOpId} className={cn(op.status === "conflict" ? noticeWarn : noticeError, "flex-col gap-2")}>
<p className="flex items-center gap-2 font-semibold text-foreground">
<TriangleAlert className="size-4.5 shrink-0 text-[var(--warn)]" aria-hidden />
{t(`op.${op.opType.replace(".", "_")}`)} · {t(`state.${op.status}`)}
</p>
<p className="text-foreground">{t(`problem.${problemKey(op)}`)}</p>
<p className="text-[13px] text-muted-foreground">{t("sync.createdAt", { time: fmtDateTimeShort(op.clientCreatedAt, locale) })}</p>
{op.status === "conflict" ? (
<button type="button" className={btnSecondary} onClick={() => void acknowledgeConflict(op.clientOpId)}>
{t("sync.hide")}
</button>
) : confirmDiscard === op.clientOpId ? (
<div className="w-full space-y-2" role="alertdialog" aria-label={t("sync.discardConfirm")}>
<p className="font-semibold text-foreground">{t("sync.discardConfirm")}</p>
<button
type="button"
className={cn(btnSecondary, "border-[var(--risk)] text-[var(--risk)]")}
onClick={async () => {
await discardOp(op.clientOpId);
setConfirmDiscard(null);
}}
>
<Trash2 className="size-5" aria-hidden />
{t("sync.discardYes")}
</button>
<button type="button" className={btnSecondary} onClick={() => setConfirmDiscard(null)}>
{t("sync.cancel")}
</button>
</div>
) : (
<div className="grid w-full grid-cols-1 gap-2 sm:grid-cols-2">
<button type="button" className={btnSecondary} onClick={() => void retryOp(op.clientOpId)}>
<RotateCcw className="size-5" aria-hidden />
{t("sync.retry")}
</button>
{canDiscard(op) && (
<button type="button" className={btnSecondary} onClick={() => setConfirmDiscard(op.clientOpId)}>
<Trash2 className="size-5" aria-hidden />
{t("sync.discard")}
</button>
)}
</div>
)}
</li>
))}
</ul>
)}
</section>
<section className={cn(card, "space-y-3")} aria-labelledby="sync-storage">
<h2 id="sync-storage" className="flex items-center gap-2 text-[17px]">
<HardDrive className="size-5 text-muted-foreground" aria-hidden />
{t("sync.storage")}
</h2>
{offlineOrders !== null && <p className="text-[15px] font-semibold">{t("sync.offlineOrders", { count: offlineOrders })}</p>}
{s.storage ? (
<>
<p className="text-[15px]">{t("sync.storageUsage", { usage: fmtBytes(s.storage.usage, locale), quota: fmtBytes(s.storage.quota, locale) })}</p>
<p className="text-[14px] text-muted-foreground">{s.storage.persisted ? t("sync.storagePersisted") : t("sync.storageNotPersisted")}</p>
</>
) : (
<p className="text-[14px] text-muted-foreground">{t("sync.storageUnknown")}</p>
)}
<p className="text-[14px] text-muted-foreground">{t("sync.saveOfflineHint")}</p>
<button type="button" className={btnSecondary} disabled={s.syncing || !s.online} onClick={() => void syncNow({ pull: true })}>
<CloudDownload className="size-5" aria-hidden />
{t("sync.saveOffline")}
</button>
<Link href="/m/offline" className={btnSecondary}>
{t("sync.openOffline")}
</Link>
</section>
<section className={cn(card, "space-y-3")} aria-labelledby="sync-reset">
<h2 id="sync-reset" className="text-[17px]">
{t("sync.reset")}
</h2>
{resetDone && (
<p className={noticeOk} role="status">
<CircleCheck className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("sync.resetDone")}
</p>
)}
{reset ? (
<div className="space-y-2" role="alertdialog" aria-label={t("sync.resetConfirm")}>
<p className="text-[15px] font-semibold">{t("sync.resetConfirm")}</p>
{reset.unsent > 0 && (
<p className={noticeError}>
<TriangleAlert className="mt-0.5 size-4.5 shrink-0" aria-hidden />
{t("sync.resetWarning", { count: reset.unsent })}
</p>
)}
<button
type="button"
className={cn(btnSecondary, "border-[var(--risk)] text-[var(--risk)]")}
onClick={async () => {
await clearLocalData({ refetch: true });
setReset(null);
setResetDone(true);
}}
>
<Trash2 className="size-5" aria-hidden />
{t("sync.resetYes")}
</button>
<button type="button" className={btnSecondary} onClick={() => setReset(null)}>
{t("sync.cancel")}
</button>
</div>
) : (
<button
type="button"
className={btnSecondary}
onClick={async () => {
setResetDone(false);
setReset({ unsent: await unsentCount() });
}}
>
<Trash2 className="size-5" aria-hidden />
{t("sync.reset")}
</button>
)}
</section>
</div>
);
}
function QueueRow({ op, locale }: { op: OutboxEntry; locale: string }) {
const t = useTranslations("offline");
return (
<li className="space-y-0.5 py-2.5 text-[15px]">
<div className="flex flex-wrap items-baseline justify-between gap-2">
<span className="font-semibold">{t(`op.${op.opType.replace(".", "_")}`)}</span>
<span className="text-[13px] font-semibold text-muted-foreground">{t(`state.${op.status}`)}</span>
</div>
<p className="text-[13px] text-muted-foreground">
{t("sync.createdAt", { time: fmtDateTimeShort(op.clientCreatedAt, locale) })}
{op.attempts > 0 && ` · ${t("sync.attempts", { count: op.attempts })}`}
{op.nextAttemptAt && ` · ${t("sync.nextAttempt", { time: fmtTimeShort(op.nextAttemptAt, locale) })}`}
</p>
{op.lastError && <p className="text-[13px]">{t(`problem.${problemKey(op)}`)}</p>}
</li>
);
}
+9 -52
View File
@@ -1,10 +1,12 @@
import type { SyncOpResult, SyncOpType, SyncResponse } from "@/lib/sync/envelope"; import type { SyncOpResult, SyncOpType } from "@/lib/sync/envelope";
import type { OpPayload } from "@/lib/sync/ops"; import type { OpPayload } from "@/lib/sync/ops";
import { submitOp as submitToOutbox } from "@/lib/offline/outbox";
/** /**
* Client wrapper for mobile mutations (ARCHITEKTUR §4.6). Today every op is sent to * Client wrapper for mobile mutations (ARCHITEKTUR §4.6). Since lane L7 every op goes through the
* POST /api/v1/sync immediately. Lane L7 replaces the implementation with the IndexedDB outbox — * IndexedDB outbox (src/lib/offline/outbox.ts): stored locally first, sent right away when online
* keep the signature `submitOp(op) → Promise<SyncOpResult>` stable. * (the server result is returned), otherwise queued and synchronised later — then the result is
* `status: "applied", message: "queued"` (check with `isQueued`). Signature unchanged.
*/ */
export type ClientOp<T extends SyncOpType = SyncOpType> = { export type ClientOp<T extends SyncOpType = SyncOpType> = {
@@ -15,56 +17,11 @@ export type ClientOp<T extends SyncOpType = SyncOpType> = {
baseVersion?: number; baseVersion?: number;
}; };
/** RFC 4122 v4 id; falls back to getRandomValues outside secure contexts. */ export { deviceId, newClientId } from "@/lib/offline/ids";
export function newClientId(): string { export { isQueued, queueBlob } from "@/lib/offline/outbox";
const c = globalThis.crypto;
if (typeof c?.randomUUID === "function") return c.randomUUID();
const b = new Uint8Array(16);
c.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
const DEVICE_KEY = "craftvia.field.deviceId";
export function deviceId(): string {
try {
let id = localStorage.getItem(DEVICE_KEY);
if (!id) {
id = newClientId();
localStorage.setItem(DEVICE_KEY, id);
}
return id;
} catch {
return "unknown-device";
}
}
function errorCodeForStatus(status: number): SyncOpResult["errorCode"] {
if (status === 400 || status === 413) return "invalid";
if (status === 401 || status === 403) return "forbidden";
if (status === 404) return "not_found";
if (status === 409) return "conflict";
return "internal";
}
export async function submitOp<T extends SyncOpType>(op: ClientOp<T>): Promise<SyncOpResult> { export async function submitOp<T extends SyncOpType>(op: ClientOp<T>): Promise<SyncOpResult> {
const clientOpId = newClientId(); return submitToOutbox(op);
try {
const res = await fetch("/api/v1/sync", {
method: "POST",
credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: deviceId(), operations: [{ ...op, clientOpId, clientCreatedAt: new Date().toISOString() }] }),
});
if (!res.ok) return { clientOpId, status: "rejected", errorCode: errorCodeForStatus(res.status), message: `HTTP ${res.status}` };
const body = (await res.json()) as SyncResponse;
return body.results[0] ?? { clientOpId, status: "rejected", errorCode: "internal" };
} catch {
return { clientOpId, status: "rejected", errorCode: "internal", message: "network" };
}
} }
/** i18n key (messages field.errors.*) for a failed op result. */ /** i18n key (messages field.errors.*) for a failed op result. */
+171
View File
@@ -0,0 +1,171 @@
import { STATUS_GROUP, type WorkOrderStatus } from "@/lib/work-orders/status";
import type { BundleOrderData, BundleRecord, OutboxEntry } from "./types";
import { isPending } from "./outbox-core";
/**
* Pure bundle logic (lane L7, Spec §23.2): which orders are kept offline, staleness, and the
* optimistic order view = server snapshot + own ops that the server has not confirmed yet (or that
* were applied after the last pull). The snapshot itself is never mutated, so a rejected op
* disappears from the view automatically.
*/
export const DEFAULT_OFFLINE_MAX_DAYS = 7;
export const PREFETCH_DAYS = 3;
const RUNNING: string[] = ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created", "technically_completed", "signature_pending"];
export function parseMaxDays(raw: string | undefined | null): number {
const n = Number.parseInt(raw ?? "", 10);
return Number.isFinite(n) && n >= 1 && n <= 365 ? n : DEFAULT_OFFLINE_MAX_DAYS;
}
/** Today + the next `days` days (local time) and all running orders. */
export function selectOfflineOrders<T extends Pick<BundleOrderData, "status" | "plannedStart" | "plannedEnd">>(orders: T[], now: Date, days = PREFETCH_DAYS): T[] {
const start = new Date(now);
start.setHours(0, 0, 0, 0);
const end = new Date(start);
end.setDate(end.getDate() + days + 1);
return orders.filter((o) => {
if (RUNNING.includes(o.status)) return true;
const ps = o.plannedStart ? new Date(o.plannedStart) : null;
const pe = o.plannedEnd ? new Date(o.plannedEnd) : null;
if (!ps) return false;
return ps < end && (pe ? pe >= start : ps >= start);
});
}
export function isBundleStale(syncedAt: string | null, now: Date, maxDays: number): boolean {
if (!syncedAt) return true;
return now.getTime() - new Date(syncedAt).getTime() > maxDays * 24 * 60 * 60 * 1000;
}
export function toBundleRecords(ctxKey: string, orders: BundleOrderData[], syncedAt: string): BundleRecord[] {
return orders.map((data) => ({ ctxKey, workOrderId: data.id, data, syncedAt }));
}
export type LocalNote = { id: string; kind: string; text: string; createdAt: string; pending: boolean };
export type LocalPhoto = { id: string; phase: string | null; comment: string | null; createdAt: string; pending: boolean; blobClientId: string | null };
export type SessionState = "en_route" | "running" | "paused" | null;
export type OrderView = BundleOrderData & {
local: {
notes: LocalNote[];
photos: LocalPhoto[];
voiceNotes: number;
session: SessionState;
pendingOps: number;
conflict: boolean;
rejected: boolean;
};
};
const str = (v: unknown): string | null => (typeof v === "string" ? v : null);
/** Server snapshot + own ops (pending, or applied but not yet contained in the snapshot). */
export function buildOrderView(record: BundleRecord, ops: OutboxEntry[]): OrderView {
const data: BundleOrderData = structuredCloneSafe(record.data);
const view: OrderView = { ...data, local: { notes: [], photos: [], voiceNotes: 0, session: initialSession(data.status), pendingOps: 0, conflict: false, rejected: false } };
const mine = ops.filter((o) => o.workOrderId === data.id).sort((a, b) => a.seq - b.seq);
for (const op of mine) {
if (op.status === "conflict" && !op.acknowledged) view.local.conflict = true;
if (op.status === "rejected" && !op.acknowledged) view.local.rejected = true;
const unconfirmed = isPending(op) || (op.status === "applied" && !!op.appliedAt && op.appliedAt > record.syncedAt);
if (!unconfirmed) continue;
if (isPending(op)) view.local.pendingOps++;
applyOp(view, op);
}
view.statusGroup = STATUS_GROUP[view.status as WorkOrderStatus] ?? view.statusGroup;
return view;
}
function initialSession(status: string): SessionState {
// The bundle carries no sessions; the order status is the best local approximation.
if (status === "en_route") return "en_route";
if (status === "in_progress") return "running";
if (status === "paused") return "paused";
return null;
}
function applyOp(view: OrderView, op: OutboxEntry) {
const p = op.payload;
switch (op.opType) {
case "session.start":
if (p.mode === "travel") {
view.status = "en_route";
view.local.session = "en_route";
} else {
view.status = "in_progress";
view.local.session = "running";
}
break;
case "session.pause":
view.status = "paused";
view.local.session = "paused";
break;
case "session.resume":
view.status = "in_progress";
view.local.session = "running";
break;
case "session.end":
view.local.session = null;
break;
case "work_order.transition":
if (typeof p.to === "string") view.status = p.to;
break;
case "note.create":
view.local.notes.unshift({ id: str(p.clientId) ?? op.clientOpId, kind: str(p.kind) ?? "general", text: str(p.text) ?? "", createdAt: op.clientCreatedAt, pending: isPending(op) });
break;
case "checklist.toggle": {
const item = view.checklistItems.find((i) => i.id === p.itemId);
if (item) {
item.checked = p.checked === true;
item.checkedAt = item.checked ? op.clientCreatedAt : null;
if (p.comment !== undefined) item.comment = str(p.comment);
}
break;
}
case "material.upsert": {
const planId = str(p.materialPlanId);
const usage = {
id: str(p.clientId) ?? op.clientOpId,
materialPlanId: planId,
name: str(p.name),
articleNumber: str(p.articleNumber),
actualQuantity: typeof p.quantity === "number" ? p.quantity : 0,
unit: str(p.unit) ?? "",
usageStatus: str(p.usageStatus) ?? "additional",
deviationReason: str(p.deviationReason),
notes: str(p.notes),
clientId: str(p.clientId),
};
const idx = planId ? view.materialUsages.findIndex((u) => u.materialPlanId === planId) : view.materialUsages.findIndex((u) => !!usage.clientId && u.clientId === usage.clientId);
if (idx >= 0) view.materialUsages[idx] = { ...view.materialUsages[idx], ...usage, id: view.materialUsages[idx].id };
else view.materialUsages.push(usage);
break;
}
case "photo.attach": {
const doc = str(p.documentId);
view.local.photos.unshift({
id: str(p.clientId) ?? op.clientOpId,
phase: str(p.phase),
comment: str(p.comment),
createdAt: op.clientCreatedAt,
pending: isPending(op),
blobClientId: doc?.startsWith("blob:") ? doc.slice(5) : null,
});
const req = view.photoRequirements.find((r) => r.id === p.photoRequirementId);
if (req) req._count = { photos: req._count.photos + 1 };
break;
}
case "voice.attach":
view.local.voiceNotes++;
break;
default:
break;
}
}
function structuredCloneSafe<T>(v: T): T {
return typeof structuredClone === "function" ? structuredClone(v) : (JSON.parse(JSON.stringify(v)) as T);
}
+136
View File
@@ -0,0 +1,136 @@
import type { BlobEntry, BundleRecord, DraftRecord, OfflineStore, OutboxEntry } from "./types";
import { createMemoryStore } from "./memory-store";
/**
* IndexedDB implementation of OfflineStore (lane L7, Spec §23.2/§23.4) — a thin promise layer over
* the native API, no dependency. Database `craftvia-offline`, stores:
* outbox key clientOpId, index ctxKey — ops with status/attempts/errors (OutboxEntry)
* blobs key clientId, index ctxKey — photos/voice notes waiting for upload (BlobEntry)
* bundle key [ctxKey, workOrderId], index ctxKey — server snapshot per order (BundleRecord)
* meta key [ctxKey, key], index ctxKey — lastPull, drafts, misc (DraftRecord)
* Every read filters by ctxKey (= tenantId:userId); data of other contexts is never returned.
*/
const DB_NAME = "craftvia-offline";
const DB_VERSION = 1;
const STORES = ["outbox", "blobs", "bundle", "meta"] as const;
type StoreName = (typeof STORES)[number];
const req = <T>(r: IDBRequest<T>) =>
new Promise<T>((resolve, reject) => {
r.onsuccess = () => resolve(r.result);
r.onerror = () => reject(r.error);
});
const done = (tx: IDBTransaction) =>
new Promise<void>((resolve, reject) => {
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error ?? new Error("transaction aborted"));
});
function open(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const r = indexedDB.open(DB_NAME, DB_VERSION);
r.onupgradeneeded = () => {
const db = r.result;
if (!db.objectStoreNames.contains("outbox")) db.createObjectStore("outbox", { keyPath: "clientOpId" }).createIndex("ctxKey", "ctxKey");
if (!db.objectStoreNames.contains("blobs")) db.createObjectStore("blobs", { keyPath: "clientId" }).createIndex("ctxKey", "ctxKey");
if (!db.objectStoreNames.contains("bundle")) db.createObjectStore("bundle", { keyPath: ["ctxKey", "workOrderId"] }).createIndex("ctxKey", "ctxKey");
if (!db.objectStoreNames.contains("meta")) db.createObjectStore("meta", { keyPath: ["ctxKey", "key"] }).createIndex("ctxKey", "ctxKey");
};
r.onsuccess = () => {
const db = r.result;
// another tab upgrades the schema → release our connection
db.onversionchange = () => db.close();
resolve(db);
};
r.onerror = () => reject(r.error);
r.onblocked = () => reject(new Error("indexedDB blocked"));
});
}
export function createIdbStore(): OfflineStore {
let dbPromise: Promise<IDBDatabase> | null = null;
const db = () => (dbPromise ??= open().catch((err) => {
dbPromise = null;
throw err;
}));
async function run<T>(names: StoreName | StoreName[], mode: IDBTransactionMode, fn: (tx: IDBTransaction) => Promise<T> | T): Promise<T> {
const tx = (await db()).transaction(names, mode);
const finished = done(tx);
const value = await fn(tx);
await finished;
return value;
}
const byCtx = <T>(name: StoreName, ctxKey: string) => run(name, "readonly", (tx) => req(tx.objectStore(name).index("ctxKey").getAll(ctxKey)) as Promise<T[]>);
return {
putOp: (entry) => run("outbox", "readwrite", (tx) => void tx.objectStore("outbox").put(entry)),
listOps: async (ctxKey) => (await byCtx<OutboxEntry>("outbox", ctxKey)).sort((a, b) => a.seq - b.seq),
deleteOp: (id) => run("outbox", "readwrite", (tx) => void tx.objectStore("outbox").delete(id)),
putBlob: (entry) => run("blobs", "readwrite", (tx) => void tx.objectStore("blobs").put(entry)),
getBlob: (id) => run("blobs", "readonly", async (tx) => ((await req(tx.objectStore("blobs").get(id))) as BlobEntry | undefined) ?? null),
listBlobs: (ctxKey) => byCtx<BlobEntry>("blobs", ctxKey),
deleteBlob: (id) => run("blobs", "readwrite", (tx) => void tx.objectStore("blobs").delete(id)),
replaceBundle: (ctxKey, records) =>
run("bundle", "readwrite", async (tx) => {
const store = tx.objectStore("bundle");
const keys = await req(store.index("ctxKey").getAllKeys(ctxKey));
for (const k of keys) store.delete(k);
for (const r of records) if (r.ctxKey === ctxKey) store.put(r);
}),
listBundle: (ctxKey) => byCtx<BundleRecord>("bundle", ctxKey),
getMeta: async <T>(ctxKey: string, key: string) =>
run("meta", "readonly", async (tx) => (((await req(tx.objectStore("meta").get([ctxKey, key]))) as DraftRecord | undefined)?.value as T | undefined) ?? null),
setMeta: (ctxKey, key, value) => run("meta", "readwrite", (tx) => void tx.objectStore("meta").put({ ctxKey, key, value, updatedAt: new Date().toISOString() } satisfies DraftRecord)),
deleteMeta: (ctxKey, key) => run("meta", "readwrite", (tx) => void tx.objectStore("meta").delete([ctxKey, key])),
listContexts: () =>
run([...STORES], "readonly", async (tx) => {
const keys = new Set<string>();
for (const name of STORES) {
// unique index keys = contexts present in this store
await new Promise<void>((resolve, reject) => {
const cur = tx.objectStore(name).index("ctxKey").openKeyCursor(null, "nextunique");
cur.onsuccess = () => {
const c = cur.result;
if (!c) return resolve();
keys.add(String(c.key));
c.continue();
};
cur.onerror = () => reject(cur.error);
});
}
return [...keys];
}),
clearContext: (ctxKey) =>
run([...STORES], "readwrite", async (tx) => {
for (const name of STORES) {
const store = tx.objectStore(name);
for (const k of await req(store.index("ctxKey").getAllKeys(ctxKey))) store.delete(k);
}
}),
};
}
let shared: OfflineStore | null = null;
/** Browser store; falls back to memory when IndexedDB is unavailable (private mode, old Safari). */
export async function getOfflineStore(): Promise<OfflineStore> {
if (shared) return shared;
if (typeof indexedDB === "undefined") return (shared = createMemoryStore());
const store = createIdbStore();
try {
await store.listContexts();
shared = store;
} catch {
shared = createMemoryStore();
}
return shared;
}
+54
View File
@@ -0,0 +1,54 @@
/**
* Document cache for offline use (lane L7). Constants are mirrored in public/sw.js (static file,
* cannot import TS) — scripts/test-offline-core.ts checks that both stay in sync.
*/
export const DOC_CACHE = "craftvia-docs-v1";
export const PAGE_CACHE = "craftvia-pages-v1";
export const STATIC_CACHE_PREFIX = "craftvia-static-";
/** Cache limit for offline documents (LRU). */
export const DOC_CACHE_MAX_BYTES = 300 * 1024 * 1024;
/** Single documents above this size are never taken offline. */
export const DOC_MAX_BYTES = 25 * 1024 * 1024;
/** Categories that are taken offline automatically (Spec §23.2: drawings, manuals, safety documents). */
export const OFFLINE_DOC_CATEGORIES = ["technical_drawing", "floor_plan", "wiring_diagram", "assembly_instructions", "safety_document"] as const;
/** URL the mobile app uses to open a document (authorised field route, see L4). */
export const docUrl = (documentId: string) => `/api/v1/field/documents/${encodeURIComponent(documentId)}`;
export type CachedDoc = { url: string; size: number; lastUsed: number };
/**
* LRU eviction: returns the urls to delete so that the cache (plus `incomingBytes`) fits into
* `maxBytes`. Urls in `keep` (documents of the current bundle) are evicted only after all others.
*/
export function selectEvictions(entries: CachedDoc[], maxBytes: number, incomingBytes = 0, keep: ReadonlySet<string> = new Set()): string[] {
let total = entries.reduce((s, e) => s + e.size, 0) + incomingBytes;
if (total <= maxBytes) return [];
const order = [...entries].sort((a, b) => {
const ka = keep.has(a.url) ? 1 : 0;
const kb = keep.has(b.url) ? 1 : 0;
return ka - kb || a.lastUsed - b.lastUsed;
});
const out: string[] = [];
for (const e of order) {
if (total <= maxBytes) break;
out.push(e.url);
total -= e.size;
}
return out;
}
type DocMeta = { id: string; category: string; fileSize: number };
/** Documents of the bundle that should be cached offline. */
export function selectOfflineDocuments<T extends DocMeta>(docs: T[]): T[] {
const seen = new Set<string>();
return docs.filter((d) => {
if (seen.has(d.id)) return false;
seen.add(d.id);
return (OFFLINE_DOC_CATEGORIES as readonly string[]).includes(d.category) && d.fileSize > 0 && d.fileSize <= DOC_MAX_BYTES;
});
}
+42
View File
@@ -0,0 +1,42 @@
import { getOfflineStore } from "./db";
import { getOfflineState, whenReady } from "./outbox";
import { ctxKeyOf } from "./types";
/**
* Form drafts in IndexedDB (lane L7, Spec §22 "automatische Zwischenspeicherung"): note text,
* report draft … Stored per tenant+user context, deleted with the local data on logout.
* `key` is a stable form id, e.g. `note:<workOrderId>` or `report:<workOrderId>:daily`.
*/
const metaKey = (key: string) => `draft:${key}`;
export async function loadDraft<T>(key: string): Promise<T | null> {
if (!(await whenReady())) return null;
const ctx = getOfflineState().ctx;
if (!ctx) return null;
try {
return await (await getOfflineStore()).getMeta<T>(ctxKeyOf(ctx), metaKey(key));
} catch {
return null;
}
}
export async function saveDraft(key: string, value: unknown): Promise<void> {
const ctx = getOfflineState().ctx;
if (!ctx) return;
try {
await (await getOfflineStore()).setMeta(ctxKeyOf(ctx), metaKey(key), value);
} catch {
// quota / private mode — the form keeps working without a draft
}
}
export async function clearDraft(key: string): Promise<void> {
const ctx = getOfflineState().ctx;
if (!ctx) return;
try {
await (await getOfflineStore()).deleteMeta(ctxKeyOf(ctx), metaKey(key));
} catch {
/* ignore */
}
}
+28
View File
@@ -0,0 +1,28 @@
/** Device-side ids (moved from src/lib/field/client-ops.ts, which re-exports them). */
/** RFC 4122 v4 id; falls back to getRandomValues outside secure contexts. */
export function newClientId(): string {
const c = globalThis.crypto;
if (typeof c?.randomUUID === "function") return c.randomUUID();
const b = new Uint8Array(16);
c.getRandomValues(b);
b[6] = (b[6] & 0x0f) | 0x40;
b[8] = (b[8] & 0x3f) | 0x80;
const h = [...b].map((x) => x.toString(16).padStart(2, "0")).join("");
return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
}
const DEVICE_KEY = "craftvia.field.deviceId";
export function deviceId(): string {
try {
let id = localStorage.getItem(DEVICE_KEY);
if (!id) {
id = newClientId();
localStorage.setItem(DEVICE_KEY, id);
}
return id;
} catch {
return "unknown-device";
}
}
Binary file not shown.
+350
View File
@@ -0,0 +1,350 @@
import type { SyncOperationInput, SyncOpResult, SyncOpType } from "@/lib/sync/envelope";
import { CONFLICTING_OPS } from "@/lib/sync/envelope";
import { ctxKeyOf, type BlobEntry, type OfflineContext, type OutboxEntry, type OutboxError } from "./types";
/**
* Pure outbox logic (lane L7, Spec §23.4/§23.5, ARCHITEKTUR §4.6) — no IndexedDB, no fetch.
* Tested in scripts/test-offline-core.ts with the in-memory store.
*
* Rules:
* - ops of one work order are sent strictly in creation order; an op that is still pending
* (backoff, waiting for its upload) blocks the later ops of the SAME order only
* - terminal outcomes (applied / conflict / rejected) never block other ops
* - blob uploads happen before the op that references them (`documentId: "blob:<clientId>"`)
* - server idMaps (client id → server id) are applied to the payloads of pending ops
* - transient failures (network, internal) → exponential backoff; deterministic ones are final
*/
export const MAX_BATCH = 50;
export const BLOB_REF_PREFIX = "blob:";
export const BACKOFF_BASE_MS = 2_000;
export const BACKOFF_MAX_MS = 5 * 60_000;
/** Ops that change WorkOrder.version on the server (status changes). */
export const VERSION_CHANGING_OPS: readonly SyncOpType[] = ["session.start", "session.pause", "session.resume", "session.end", "work_order.transition", "report.submit"];
const TRANSIENT_CODES = new Set<OutboxError["code"]>(["internal", "network"]);
export type QueuedOpInput = {
opType: SyncOpType;
payload: Record<string, unknown>;
entityType?: string;
entityId?: string;
baseVersion?: number;
};
export const blobRef = (clientId: string) => `${BLOB_REF_PREFIX}${clientId}`;
/** Client ids of all blob references in a payload (deep). */
export function blobRefsIn(value: unknown): string[] {
const out = new Set<string>();
const walk = (v: unknown) => {
if (typeof v === "string") {
if (v.startsWith(BLOB_REF_PREFIX)) out.add(v.slice(BLOB_REF_PREFIX.length));
} else if (Array.isArray(v)) v.forEach(walk);
else if (v && typeof v === "object") Object.values(v).forEach(walk);
};
walk(value);
return [...out];
}
export function workOrderIdOf(op: Pick<QueuedOpInput, "payload" | "entityType" | "entityId">): string | null {
const fromPayload = op.payload.workOrderId;
if (typeof fromPayload === "string" && fromPayload) return fromPayload;
return op.entityType === "work_order" && op.entityId ? op.entityId : null;
}
export const isPending = (e: Pick<OutboxEntry, "status">) => e.status === "queued" || e.status === "sending";
/** Exponential backoff with jitter (0.5–1.0 × of the step); `random` injectable for tests. */
export function backoffMs(attempts: number, random: () => number = Math.random): number {
const step = Math.min(BACKOFF_MAX_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempts - 1));
return Math.round(step * (0.5 + random() * 0.5));
}
let seqCounter = 0;
/** Monotonic per device (ms timestamp × 1000 + counter). */
export function nextSeq(now: Date): number {
seqCounter = (seqCounter + 1) % 1000;
return now.getTime() * 1000 + seqCounter;
}
export function createEntry(ctx: OfflineContext, op: QueuedOpInput, opts: { clientOpId: string; now: Date; existing: OutboxEntry[]; seq?: number }): OutboxEntry {
const workOrderId = workOrderIdOf(op);
const iso = opts.now.toISOString();
// A conflict-checked op queued behind our own pending status change of the same order takes its
// baseVersion from that op's server result — otherwise our own chain would always conflict.
const chainedBase =
CONFLICTING_OPS.includes(op.opType) &&
!!workOrderId &&
opts.existing.some((e) => e.workOrderId === workOrderId && isPending(e) && VERSION_CHANGING_OPS.includes(e.opType));
return {
clientOpId: opts.clientOpId,
ctxKey: ctxKeyOf(ctx),
tenantId: ctx.tenantId,
userId: ctx.userId,
opType: op.opType,
payload: op.payload,
entityType: op.entityType,
entityId: op.entityId,
baseVersion: op.baseVersion,
chainedBase: chainedBase || undefined,
workOrderId,
seq: opts.seq ?? nextSeq(opts.now),
status: "queued",
attempts: 0,
lastError: null,
clientCreatedAt: iso,
updatedAt: iso,
nextAttemptAt: null,
appliedAt: null,
blobRefs: blobRefsIn(op.payload),
result: null,
};
}
const orderKey = (e: OutboxEntry) => e.workOrderId ?? "_global";
const due = (at: string | null, now: Date) => !at || new Date(at).getTime() <= now.getTime();
/** Entries left in `sending` by an interrupted pass (tab closed, crash) go back to the queue. */
export function recoverSending(ops: OutboxEntry[], now: Date): OutboxEntry[] {
return ops.filter((o) => o.status === "sending").map((o) => ({ ...o, status: "queued" as const, updatedAt: now.toISOString() }));
}
/** Blobs that must be uploaded before the next batch (referenced by pending ops, not in backoff). */
export function selectUploads(ops: OutboxEntry[], blobs: BlobEntry[], now: Date): BlobEntry[] {
const byId = new Map(blobs.map((b) => [b.clientId, b]));
const out: BlobEntry[] = [];
const seen = new Set<string>();
for (const op of [...ops].sort((a, b) => a.seq - b.seq)) {
if (op.status !== "queued") continue;
for (const ref of op.blobRefs) {
const b = byId.get(ref);
if (!b || seen.has(ref) || b.status === "uploaded") continue;
if (b.status === "failed" && b.nextAttemptAt === null) continue; // final failure
if (!due(b.nextAttemptAt, now)) continue;
seen.add(ref);
out.push(b);
}
}
return out;
}
/** Payload with blob references replaced by their uploaded documentIds; null if an upload is missing. */
export function resolvePayload(op: OutboxEntry, blobs: BlobEntry[]): Record<string, unknown> | null {
if (op.blobRefs.length === 0) return op.payload;
const docs = new Map(blobs.filter((b) => b.status === "uploaded" && b.documentId).map((b) => [b.clientId, b.documentId as string]));
if (!op.blobRefs.every((r) => docs.has(r))) return null;
const walk = (v: unknown): unknown => {
if (typeof v === "string" && v.startsWith(BLOB_REF_PREFIX)) return docs.get(v.slice(BLOB_REF_PREFIX.length)) ?? v;
if (Array.isArray(v)) return v.map(walk);
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x)]));
return v;
};
return walk(op.payload) as Record<string, unknown>;
}
export type Batch = { entries: OutboxEntry[]; operations: SyncOperationInput[] };
/**
* Next batch (max `max` ops): in seq order; per work order stop at the first op that cannot be
* sent yet (backoff, missing upload, failed upload) — later ops of other orders still go.
*/
export function selectBatch(ops: OutboxEntry[], blobs: BlobEntry[], now: Date, max = MAX_BATCH): Batch {
const blocked = new Set<string>();
const entries: OutboxEntry[] = [];
const operations: SyncOperationInput[] = [];
for (const op of [...ops].sort((a, b) => a.seq - b.seq)) {
if (entries.length >= max) break;
const key = orderKey(op);
if (op.status === "sending") {
blocked.add(key);
continue;
}
if (op.status !== "queued") continue;
if (blocked.has(key)) continue;
if (!due(op.nextAttemptAt, now)) {
blocked.add(key);
continue;
}
const payload = resolvePayload(op, blobs);
if (!payload) {
blocked.add(key);
continue;
}
entries.push(op);
operations.push({
clientOpId: op.clientOpId,
opType: op.opType,
...(op.entityType ? { entityType: op.entityType } : {}),
...(op.entityId ? { entityId: op.entityId } : {}),
...(op.baseVersion !== undefined ? { baseVersion: op.baseVersion } : {}),
payload,
clientCreatedAt: op.clientCreatedAt,
});
}
return { entries, operations };
}
/** Replaces client ids by server ids in a payload (deep). The own `clientId` field stays (idempotency). */
export function applyIdMapToPayload(payload: Record<string, unknown>, idMap: Record<string, string>): Record<string, unknown> {
const walk = (v: unknown, key?: string): unknown => {
if (typeof v === "string") return key !== "clientId" && Object.hasOwn(idMap, v) ? idMap[v] : v;
if (Array.isArray(v)) return v.map((x) => walk(x));
if (v && typeof v === "object") return Object.fromEntries(Object.entries(v).map(([k, x]) => [k, walk(x, k)]));
return v;
};
return walk(payload) as Record<string, unknown>;
}
function originalStatus(result: SyncOpResult): "applied" | "conflict" | "rejected" {
if (!result.errorCode) return "applied";
if (result.errorCode === "conflict" && /original status: conflict/.test(result.message ?? "")) return "conflict";
return "rejected";
}
/**
* Applies the server results of a batch. Returns all changed entries (batch entries and pending
* entries rewritten by idMap / chained baseVersion) plus the merged idMap.
*/
export function applyResults(
ops: OutboxEntry[],
batch: OutboxEntry[],
results: SyncOpResult[],
now: Date,
random: () => number = Math.random,
): { changed: OutboxEntry[]; idMap: Record<string, string> } {
const iso = now.toISOString();
const byOp = new Map(results.map((r) => [r.clientOpId, r]));
const changed = new Map<string, OutboxEntry>();
const idMap: Record<string, string> = {};
const versions = new Map<string, number>();
for (const entry of batch) {
const r = byOp.get(entry.clientOpId);
let next: OutboxEntry;
if (!r) {
next = transient(entry, { code: "internal", message: "missing result" }, now, random);
} else {
const status = r.status === "duplicate" ? originalStatus(r) : r.status;
if (status === "applied") {
next = { ...entry, status: "applied", appliedAt: iso, updatedAt: iso, lastError: null, nextAttemptAt: null, result: r };
Object.assign(idMap, r.idMap ?? {});
if (entry.workOrderId && r.entityVersion !== undefined) versions.set(entry.workOrderId, r.entityVersion);
} else if (status === "conflict") {
next = { ...entry, status: "conflict", updatedAt: iso, lastError: { code: "conflict", message: r.message }, nextAttemptAt: null, result: r };
} else {
const code = r.errorCode ?? "internal";
next = TRANSIENT_CODES.has(code)
? transient(entry, { code, message: r.message }, now, random)
: { ...entry, status: "rejected", updatedAt: iso, lastError: { code, message: r.message }, nextAttemptAt: null, result: r };
}
}
changed.set(next.clientOpId, next);
}
const inBatch = new Set(batch.map((b) => b.clientOpId));
const hasIds = Object.keys(idMap).length > 0;
for (const op of ops) {
if (inBatch.has(op.clientOpId) || !isPending(op)) continue;
let next = op;
if (hasIds) {
const payload = applyIdMapToPayload(op.payload, idMap);
if (JSON.stringify(payload) !== JSON.stringify(op.payload)) next = { ...next, payload, updatedAt: iso };
}
if (next.chainedBase && next.workOrderId && versions.has(next.workOrderId)) {
next = { ...next, baseVersion: versions.get(next.workOrderId), updatedAt: iso };
}
if (next !== op) changed.set(next.clientOpId, next);
}
// a chained op in the same batch already went out with the old baseVersion — nothing to do there
return { changed: [...changed.values()], idMap };
}
function transient(entry: OutboxEntry, error: OutboxError, now: Date, random: () => number): OutboxEntry {
const attempts = entry.attempts + 1;
return {
...entry,
status: "queued",
attempts,
lastError: error,
updatedAt: now.toISOString(),
nextAttemptAt: new Date(now.getTime() + backoffMs(attempts, random)).toISOString(),
};
}
/** Whole batch failed on transport level (offline, 5xx, 401). */
export function applyTransportFailure(batch: OutboxEntry[], error: OutboxError, now: Date, random: () => number = Math.random): OutboxEntry[] {
return batch.map((e) => transient(e, error, now, random));
}
/** Manual "erneut versuchen": transient entries retry now; rejected ones get a fresh clientOpId (the server stored the old outcome). */
export function retryEntry(entry: OutboxEntry, now: Date, newClientOpId: string): OutboxEntry | null {
if (entry.status === "applied" || entry.status === "conflict" || entry.status === "sending") return null;
const base = { ...entry, status: "queued" as const, nextAttemptAt: null, updatedAt: now.toISOString(), acknowledged: undefined };
return entry.status === "rejected" ? { ...base, clientOpId: newClientOpId, attempts: 0, lastError: null, result: null } : base;
}
/** Only rejected entries can be discarded (conflicts stay listed; they are reviewed in the office). */
export const canDiscard = (e: Pick<OutboxEntry, "status">) => e.status === "rejected";
export type OutboxSummary = {
pendingOps: number;
pendingUploads: number;
uploadBytes: number;
failedUploads: number;
conflicts: number;
rejected: number;
/** oldest pending op (ISO) */
oldestPending: string | null;
waitingForAuth: boolean;
};
export function summarize(ops: OutboxEntry[], blobs: BlobEntry[]): OutboxSummary {
const pending = ops.filter(isPending);
const openBlobs = blobs.filter((b) => b.status !== "uploaded");
return {
pendingOps: pending.length,
pendingUploads: openBlobs.filter((b) => b.status !== "failed" || b.nextAttemptAt !== null).length,
uploadBytes: openBlobs.reduce((s, b) => s + b.size, 0),
failedUploads: openBlobs.filter((b) => b.status === "failed" && b.nextAttemptAt === null).length,
conflicts: ops.filter((o) => o.status === "conflict" && !o.acknowledged).length,
rejected: ops.filter((o) => o.status === "rejected" && !o.acknowledged).length,
oldestPending: pending.length ? pending.reduce((m, o) => (o.clientCreatedAt < m ? o.clientCreatedAt : m), pending[0].clientCreatedAt) : null,
waitingForAuth: pending.some((o) => o.lastError?.code === "unauthorized"),
};
}
/** Entries that can be removed: applied before the last bundle pull, acknowledged rejections. */
export function prunable(ops: OutboxEntry[], bundleSyncedAt: string | null): string[] {
return ops
.filter((o) => (o.status === "applied" && !!o.appliedAt && !!bundleSyncedAt && o.appliedAt <= bundleSyncedAt) || (o.status === "rejected" && o.acknowledged))
.map((o) => o.clientOpId);
}
/** i18n key (messages offline.problem.*) explaining a failed entry in plain language. */
export function problemKey(e: Pick<OutboxEntry, "status" | "opType" | "lastError">): string {
if (e.status === "conflict") {
if (e.opType === "work_order.transition") return "conflictTransition";
if (e.opType === "report.submit") return "conflictReport";
return "conflict";
}
switch (e.lastError?.code) {
case "not_found":
return "notFound";
case "forbidden":
return "forbidden";
case "blocked":
return "blocked";
case "invalid":
return "invalid";
case "upload":
return "upload";
case "unauthorized":
return "unauthorized";
case "network":
return "network";
default:
return "internal";
}
}
+425
View File
@@ -0,0 +1,425 @@
import type { SyncOpResult, SyncResponse } from "@/lib/sync/envelope";
import { getOfflineStore } from "./db";
import { deviceId, newClientId } from "./ids";
import { canDiscard, isPending, prunable, retryEntry, summarize, type OutboxSummary, type QueuedOpInput } from "./outbox-core";
import { enqueueBlob, enqueueOp, runSyncPass, type BlobInput, type Transport, type UploadOutcome } from "./sync-engine";
import { clearUserCaches, META_LAST_PULL, pullBundle, requestPersistence, storageInfo, type StorageInfo } from "./prefetch";
import { DEFAULT_OFFLINE_MAX_DAYS } from "./bundle-core";
import { ctxKeyOf, type BlobEntry, type OfflineContext, type OfflineStore, type OutboxEntry } from "./types";
/**
* Browser outbox (lane L7, Spec §23.4, ARCHITEKTUR §4.6): IndexedDB store + sync loop.
* `submitOp` (re-exported by src/lib/field/client-ops.ts, signature unchanged) stores every op
* locally first, then — when online — runs a sync pass right away and returns the server result.
* Offline it returns immediately with `message: "queued"` (see `isQueued`).
*
* Triggers: online event, every 30 s, tab becomes visible, "Jetzt synchronisieren", Background
* Sync message from the service worker (Chromium only).
*/
export const SYNC_INTERVAL_MS = 30_000;
const PULL_MIN_INTERVAL_MS = 5 * 60_000;
const SUBMIT_WAIT_MS = 15_000;
const LAST_CTX_KEY = "craftvia.offline.lastContext";
export type OfflineState = {
ready: boolean;
ctx: OfflineContext | null;
maxDays: number;
online: boolean;
syncing: boolean;
summary: OutboxSummary;
lastSyncAt: string | null;
lastPullAt: string | null;
lastError: null | "network" | "unauthorized" | "internal";
storage: StorageInfo;
/** upload progress per blob client id (0–100) */
uploads: Record<string, number>;
};
const EMPTY_SUMMARY: OutboxSummary = { pendingOps: 0, pendingUploads: 0, uploadBytes: 0, failedUploads: 0, conflicts: 0, rejected: 0, oldestPending: null, waitingForAuth: false };
let state: OfflineState = {
ready: false,
ctx: null,
maxDays: DEFAULT_OFFLINE_MAX_DAYS,
online: true,
syncing: false,
summary: EMPTY_SUMMARY,
lastSyncAt: null,
lastPullAt: null,
lastError: null,
storage: null,
uploads: {},
};
const listeners = new Set<() => void>();
let readyWaiters: Array<() => void> = [];
function setState(patch: Partial<OfflineState>) {
state = { ...state, ...patch };
listeners.forEach((l) => l());
}
export const getOfflineState = () => state;
export const getServerOfflineState = () => state;
export function subscribeOffline(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
/** Resolves once the mobile shell configured the context (or after `timeoutMs`, then false). */
export function whenReady(timeoutMs = 3000): Promise<boolean> {
if (state.ready) return Promise.resolve(true);
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(false), timeoutMs);
readyWaiters.push(() => {
clearTimeout(timer);
resolve(true);
});
});
}
async function requireRuntime(): Promise<{ store: OfflineStore; ctx: OfflineContext } | null> {
if (!state.ctx) return null;
return { store: await getOfflineStore(), ctx: state.ctx };
}
// ---------------------------------------------------------------- transport (fetch / XHR)
const uploadProgress = new Map<string, (percent: number) => void>();
const browserTransport: Transport = {
async sendBatch(device, operations) {
let res: Response;
try {
res = await fetch("/api/v1/sync", {
method: "POST",
credentials: "same-origin",
cache: "no-store",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ deviceId: device, operations }),
});
} catch {
return { ok: false, error: "network" };
}
if (res.status === 401) return { ok: false, error: "unauthorized" };
if (!res.ok) return { ok: false, error: "internal" };
try {
return { ok: true, response: (await res.json()) as SyncResponse };
} catch {
return { ok: false, error: "internal" };
}
},
upload(blob: BlobEntry) {
return new Promise<UploadOutcome>((resolve) => {
const form = new FormData();
form.append("clientId", blob.clientId);
form.append("workOrderId", blob.workOrderId);
form.append("kind", blob.kind);
form.append("file", blob.blob, blob.fileName);
if (blob.preview) form.append("preview", blob.preview, `thumb-${blob.fileName}`);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/v1/uploads");
xhr.withCredentials = true;
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const percent = Math.round((e.loaded / e.total) * 100);
uploadProgress.get(blob.clientId)?.(percent);
setState({ uploads: { ...state.uploads, [blob.clientId]: percent } });
};
xhr.onerror = () => resolve({ ok: false, error: "network" });
xhr.onload = () => {
const { [blob.clientId]: _done, ...rest } = state.uploads;
void _done;
setState({ uploads: rest });
if (xhr.status === 200 || xhr.status === 201) {
try {
resolve({ ok: true, documentId: (JSON.parse(xhr.responseText) as { documentId: string }).documentId });
} catch {
resolve({ ok: false, error: "internal" });
}
return;
}
if (xhr.status === 401) return resolve({ ok: false, error: "unauthorized" });
if (xhr.status === 400 || xhr.status === 413) return resolve({ ok: false, error: "invalid" });
if (xhr.status === 403) return resolve({ ok: false, error: "forbidden" });
if (xhr.status === 404) return resolve({ ok: false, error: "not_found" });
resolve({ ok: false, error: xhr.status === 0 ? "network" : "internal" });
};
xhr.send(form);
});
},
};
// ---------------------------------------------------------------- configuration / lifecycle
/**
* Called by the mobile shell (OfflineRuntimeClient) with the signed-in tenant/user. Removes local
* data of other contexts that has nothing left to send, and the page/document caches when the
* device changed hands (other user or tenant).
*/
export async function configureOffline(ctx: OfflineContext, opts: { maxDays: number }): Promise<void> {
if (state.ctx && ctxKeyOf(state.ctx) === ctxKeyOf(ctx) && state.maxDays === opts.maxDays) return;
const store = await getOfflineStore();
const key = ctxKeyOf(ctx);
try {
const previous = localStorage.getItem(LAST_CTX_KEY);
if (previous && previous !== key) await clearUserCaches();
localStorage.setItem(LAST_CTX_KEY, key);
} catch {
// storage blocked — caches stay (they are cleared on logout)
}
for (const other of await store.listContexts()) {
if (other === key) continue;
const [ops, blobs] = await Promise.all([store.listOps(other), store.listBlobs(other)]);
if (!ops.some(isPending) && blobs.every((b) => b.status === "uploaded")) await store.clearContext(other);
}
setState({
ready: true,
ctx,
maxDays: opts.maxDays,
online: typeof navigator === "undefined" ? true : navigator.onLine,
lastPullAt: await store.getMeta<string>(key, META_LAST_PULL),
lastSyncAt: await store.getMeta<string>(key, "lastSyncAt"),
});
readyWaiters.forEach((w) => w());
readyWaiters = [];
await refreshSummary();
void requestPersistence();
}
export async function refreshSummary(): Promise<void> {
const rt = await requireRuntime();
if (!rt) return;
const key = ctxKeyOf(rt.ctx);
const [ops, blobs, storage] = await Promise.all([rt.store.listOps(key), rt.store.listBlobs(key), storageInfo()]);
setState({ summary: summarize(ops, blobs), storage });
}
let running: Promise<void> | null = null;
let rerun = false;
/** Runs a sync pass (single flight per tab, serialised across tabs via Web Locks when available). */
export function syncNow(opts: { pull?: boolean } = {}): Promise<void> {
if (running) {
rerun = true;
return running;
}
running = (async () => {
do {
rerun = false;
await withLock(() => pass(opts.pull === true));
} while (rerun);
})().finally(() => {
running = null;
});
return running;
}
async function withLock(fn: () => Promise<void>) {
const locks = typeof navigator !== "undefined" ? (navigator as Navigator & { locks?: LockManager }).locks : undefined;
if (locks?.request && state.ctx) await locks.request(`craftvia-sync:${ctxKeyOf(state.ctx)}`, fn);
else await fn();
}
async function pass(forcePull: boolean) {
const rt = await requireRuntime();
if (!rt) return;
const key = ctxKeyOf(rt.ctx);
setState({ syncing: true, online: navigator.onLine });
try {
const result = await runSyncPass({ store: rt.store, ctx: rt.ctx, transport: browserTransport, deviceId: deviceId() });
const now = new Date().toISOString();
if (result.stopped) {
setState({ lastError: result.stopped });
} else {
await rt.store.setMeta(key, "lastSyncAt", now);
setState({ lastSyncAt: now, lastError: null });
const lastPull = state.lastPullAt ? new Date(state.lastPullAt).getTime() : 0;
if (forcePull || result.sent > 0 || result.uploaded > 0 || Date.now() - lastPull > PULL_MIN_INTERVAL_MS) {
const pulled = await pullBundle(rt.store, rt.ctx);
if (pulled.ok) setState({ lastPullAt: pulled.syncedAt });
else if (pulled.error !== "network") setState({ lastError: pulled.error });
}
}
const ops = await rt.store.listOps(key);
for (const id of prunable(ops, state.lastPullAt)) await rt.store.deleteOp(id);
// files that never got an op (form closed before saving) are dropped after a day
const dayAgo = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
for (const b of await rt.store.listBlobs(key)) {
if (!b.opClientOpId && b.createdAt < dayAgo && !ops.some((o) => o.blobRefs.includes(b.clientId))) await rt.store.deleteBlob(b.clientId);
}
} finally {
setState({ syncing: false });
await refreshSummary();
}
}
/** Starts the automatic sync triggers; returns the cleanup function. */
export function startSyncLoop(): () => void {
const onOnline = () => {
setState({ online: true });
void syncNow();
};
const onOffline = () => setState({ online: false });
const onVisible = () => {
if (document.visibilityState === "visible" && navigator.onLine) void syncNow();
};
const onMessage = (e: MessageEvent) => {
if ((e.data as { type?: string } | null)?.type === "craftvia:sync") void syncNow();
};
window.addEventListener("online", onOnline);
window.addEventListener("offline", onOffline);
document.addEventListener("visibilitychange", onVisible);
navigator.serviceWorker?.addEventListener("message", onMessage);
const timer = setInterval(() => {
if (navigator.onLine) void syncNow();
}, SYNC_INTERVAL_MS);
if (navigator.onLine) void syncNow();
return () => {
window.removeEventListener("online", onOnline);
window.removeEventListener("offline", onOffline);
document.removeEventListener("visibilitychange", onVisible);
navigator.serviceWorker?.removeEventListener("message", onMessage);
clearInterval(timer);
};
}
function registerBackgroundSync() {
navigator.serviceWorker?.ready
.then((reg) => (reg as ServiceWorkerRegistration & { sync?: { register(tag: string): Promise<void> } }).sync?.register("craftvia-outbox"))
.catch(() => undefined);
}
// ---------------------------------------------------------------- submit API
type SubmitInput = { opType: QueuedOpInput["opType"]; payload: unknown; entityType?: string; entityId?: string; baseVersion?: number };
/** Result for an op that is stored locally and will be sent later. */
export const queuedResult = (clientOpId: string): SyncOpResult => ({ clientOpId, status: "applied", message: "queued" });
export const isQueued = (r: SyncOpResult) => r.status === "applied" && r.message === "queued";
async function directSubmit(op: SubmitInput, clientOpId: string): Promise<SyncOpResult> {
const outcome = await browserTransport.sendBatch(deviceId(), [
{ clientOpId, opType: op.opType, payload: op.payload as Record<string, unknown>, entityType: op.entityType, entityId: op.entityId, baseVersion: op.baseVersion, clientCreatedAt: new Date().toISOString() },
]);
if (!outcome.ok) return { clientOpId, status: "rejected", errorCode: outcome.error === "unauthorized" ? "forbidden" : "internal", message: outcome.error === "network" ? "network" : undefined };
return outcome.response.results[0] ?? { clientOpId, status: "rejected", errorCode: "internal" };
}
export async function submitOp(op: SubmitInput): Promise<SyncOpResult> {
const clientOpId = newClientId();
const rt = (await whenReady(1500)) ? await requireRuntime() : null;
// outside the mobile shell (no context): behave like before — send immediately
if (!rt) return directSubmit(op, clientOpId);
const key = ctxKeyOf(rt.ctx);
await enqueueOp(rt.store, rt.ctx, { ...op, payload: op.payload as Record<string, unknown> }, clientOpId);
registerBackgroundSync();
await refreshSummary();
if (!navigator.onLine) return queuedResult(clientOpId);
await Promise.race([syncNow(), new Promise((r) => setTimeout(r, SUBMIT_WAIT_MS))]);
const entry = (await rt.store.listOps(key)).find((e) => e.clientOpId === clientOpId);
if (!entry || isPending(entry)) return queuedResult(clientOpId);
if (entry.status === "applied") return entry.result ?? { clientOpId, status: "applied" };
if (entry.status === "rejected") {
// shown inline by the calling form → not repeated in the error list of /m/sync
await rt.store.putOp({ ...entry, acknowledged: true });
await refreshSummary();
}
return entry.result ?? { clientOpId, status: "rejected", errorCode: entry.lastError?.code === "upload" ? "invalid" : "internal", message: entry.lastError?.message };
}
/**
* Queues a photo/voice note for upload; returns the reference for the op payload (`documentId`).
* Outside the mobile shell the file is uploaded directly and the real documentId is returned.
*/
export async function queueBlob(input: Omit<BlobInput, "clientId"> & { onProgress?: (percent: number) => void }): Promise<{ ok: true; documentId: string } | { ok: false; error: string }> {
const clientId = newClientId();
const rt = (await whenReady(1500)) ? await requireRuntime() : null;
if (!rt) {
if (input.onProgress) uploadProgress.set(clientId, input.onProgress);
const up = await browserTransport.upload({ clientId, workOrderId: input.workOrderId, kind: input.kind, blob: input.blob, preview: input.preview ?? null, fileName: input.fileName } as BlobEntry);
uploadProgress.delete(clientId);
return up.ok ? { ok: true, documentId: up.documentId } : { ok: false, error: up.error };
}
if (input.onProgress) {
const cb = input.onProgress;
uploadProgress.set(clientId, (p) => {
cb(p);
if (p >= 100) uploadProgress.delete(clientId);
});
}
try {
return { ok: true, documentId: await enqueueBlob(rt.store, rt.ctx, { ...input, clientId }) };
} catch {
// quota exceeded
return { ok: false, error: "storage" };
}
}
// ---------------------------------------------------------------- /m/sync actions
export type OutboxListing = { ops: OutboxEntry[]; blobs: BlobEntry[] };
export async function listOutbox(): Promise<OutboxListing> {
const rt = await requireRuntime();
if (!rt) return { ops: [], blobs: [] };
const key = ctxKeyOf(rt.ctx);
return { ops: await rt.store.listOps(key), blobs: await rt.store.listBlobs(key) };
}
export async function retryOp(clientOpId: string): Promise<void> {
const rt = await requireRuntime();
if (!rt) return;
const entry = (await rt.store.listOps(ctxKeyOf(rt.ctx))).find((e) => e.clientOpId === clientOpId);
const next = entry ? retryEntry(entry, new Date(), newClientId()) : null;
if (!entry || !next) return;
if (next.clientOpId !== entry.clientOpId) await rt.store.deleteOp(entry.clientOpId);
await rt.store.putOp(next);
// a failed upload of this op gets a fresh chance as well
for (const ref of next.blobRefs) {
const blob = await rt.store.getBlob(ref);
if (blob && blob.status === "failed") await rt.store.putBlob({ ...blob, status: "pending", nextAttemptAt: null, lastError: null, opClientOpId: next.clientOpId });
}
await refreshSummary();
if (navigator.onLine) void syncNow();
}
/** Discards a rejected entry (after confirmation in the UI) including its local file. */
export async function discardOp(clientOpId: string): Promise<boolean> {
const rt = await requireRuntime();
if (!rt) return false;
const entry = (await rt.store.listOps(ctxKeyOf(rt.ctx))).find((e) => e.clientOpId === clientOpId);
if (!entry || !canDiscard(entry)) return false;
await rt.store.deleteOp(entry.clientOpId);
for (const ref of entry.blobRefs) await rt.store.deleteBlob(ref);
await refreshSummary();
return true;
}
/** Hides a conflict notice on this device (the conflict itself stays in the office list). */
export async function acknowledgeConflict(clientOpId: string): Promise<void> {
const rt = await requireRuntime();
if (!rt) return;
const entry = (await rt.store.listOps(ctxKeyOf(rt.ctx))).find((e) => e.clientOpId === clientOpId);
if (entry?.status === "conflict") await rt.store.putOp({ ...entry, acknowledged: true });
await refreshSummary();
}
/** Number of ops/uploads that would be lost when the local data of this context is deleted. */
export async function unsentCount(): Promise<number> {
const { ops, blobs } = await listOutbox();
return ops.filter(isPending).length + blobs.filter((b) => b.status !== "uploaded" && !ops.some((o) => o.blobRefs.includes(b.clientId) && isPending(o))).length;
}
/** Deletes all local data of the current context (logout, "lokale Daten zurücksetzen"). */
export async function clearLocalData(opts: { refetch?: boolean } = {}): Promise<void> {
const rt = await requireRuntime();
if (rt) await rt.store.clearContext(ctxKeyOf(rt.ctx));
await clearUserCaches();
setState({ lastPullAt: null, lastSyncAt: null, lastError: null });
await refreshSummary();
if (opts.refetch && navigator.onLine) void syncNow({ pull: true });
}
+122
View File
@@ -0,0 +1,122 @@
import { selectOfflineOrders, toBundleRecords } from "./bundle-core";
import { DOC_CACHE, DOC_CACHE_MAX_BYTES, docUrl, PAGE_CACHE, selectEvictions, selectOfflineDocuments, type CachedDoc } from "./doc-cache";
import { ctxKeyOf, type BundleOrderData, type OfflineContext, type OfflineStore } from "./types";
/**
* "Für offline speichern" (lane L7, Spec §23.2): pulls GET /api/v1/field/bundle, keeps today's,
* the next 3 days' and all running orders in IndexedDB and caches their drawings / manuals /
* safety documents (≤ 25 MB) in the document cache (LRU, 300 MB) that the service worker serves.
*/
export const META_LAST_PULL = "lastPullAt";
export type PullResult = { ok: true; orders: number; documents: number; syncedAt: string } | { ok: false; error: "network" | "unauthorized" | "internal" };
export async function pullBundle(store: OfflineStore, ctx: OfflineContext): Promise<PullResult> {
let res: Response;
try {
res = await fetch("/api/v1/field/bundle", { credentials: "same-origin", cache: "no-store", headers: { Accept: "application/json" } });
} catch {
return { ok: false, error: "network" };
}
if (res.status === 401) return { ok: false, error: "unauthorized" };
if (!res.ok || res.redirected) return { ok: false, error: "internal" };
let body: { serverTime: string; orders: BundleOrderData[] };
try {
body = (await res.json()) as typeof body;
} catch {
return { ok: false, error: "internal" };
}
const key = ctxKeyOf(ctx);
const selected = selectOfflineOrders(body.orders, new Date());
await store.replaceBundle(key, toBundleRecords(key, selected, body.serverTime));
await store.setMeta(key, META_LAST_PULL, body.serverTime);
const docs = selectOfflineDocuments(selected.flatMap((o) => o.documents));
const documents = await cacheDocuments(docs).catch(() => 0);
return { ok: true, orders: selected.length, documents, syncedAt: body.serverTime };
}
const pathOf = (u: string) => new URL(u, location.origin).pathname;
async function cachedEntries(cache: Cache): Promise<CachedDoc[]> {
const out: CachedDoc[] = [];
for (const request of await cache.keys()) {
const hit = await cache.match(request);
out.push({ url: pathOf(request.url), size: Number(hit?.headers.get("x-craftvia-size") ?? 0), lastUsed: Number(hit?.headers.get("x-craftvia-used") ?? 0) });
}
return out;
}
/** Downloads missing documents into the SW document cache; returns the number of cached bundle documents. */
export async function cacheDocuments(docs: Array<{ id: string; fileSize: number }>): Promise<number> {
if (typeof caches === "undefined") return 0;
const cache = await caches.open(DOC_CACHE);
let entries = await cachedEntries(cache);
const keep = new Set(docs.map((d) => docUrl(d.id)));
let cached = 0;
for (const doc of docs) {
const url = docUrl(doc.id);
if (entries.some((e) => e.url === url)) {
cached++;
continue;
}
for (const evict of selectEvictions(entries, DOC_CACHE_MAX_BYTES, doc.fileSize, keep)) {
await cache.delete(evict);
entries = entries.filter((e) => e.url !== evict);
}
try {
const res = await fetch(url, { credentials: "same-origin", cache: "no-store" });
if (!res.ok || res.redirected) continue;
const blob = await res.blob();
const headers = new Headers();
for (const h of ["content-type", "content-disposition", "x-content-type-options"]) {
const v = res.headers.get(h);
if (v) headers.set(h, v);
}
headers.set("x-craftvia-size", String(blob.size));
headers.set("x-craftvia-used", String(Date.now()));
await cache.put(url, new Response(blob, { status: 200, headers }));
entries.push({ url, size: blob.size, lastUsed: Date.now() });
cached++;
} catch {
// offline or quota exceeded — try again on the next sync
}
}
return cached;
}
/** Ids of documents available offline (for the "offline verfügbar" hint). */
export async function cachedDocumentIds(): Promise<Set<string>> {
if (typeof caches === "undefined") return new Set();
const cache = await caches.open(DOC_CACHE);
const prefix = docUrl("");
return new Set((await cache.keys()).map((r) => pathOf(r.url)).filter((p) => p.startsWith(prefix)).map((p) => decodeURIComponent(p.slice(prefix.length))));
}
/** Pages and documents of the previous user/tenant must not stay on a shared device. */
export async function clearUserCaches(): Promise<void> {
if (typeof caches === "undefined") return;
await Promise.all([caches.delete(PAGE_CACHE), caches.delete(DOC_CACHE)]);
}
export type StorageInfo = { usage: number; quota: number; persisted: boolean } | null;
export async function storageInfo(): Promise<StorageInfo> {
if (typeof navigator === "undefined" || !navigator.storage?.estimate) return null;
try {
const [est, persisted] = await Promise.all([navigator.storage.estimate(), navigator.storage.persisted?.() ?? Promise.resolve(false)]);
return { usage: est.usage ?? 0, quota: est.quota ?? 0, persisted };
} catch {
return null;
}
}
/** Asks the browser not to evict offline data under storage pressure (granted silently or not at all). */
export async function requestPersistence(): Promise<boolean> {
try {
if (!navigator.storage?.persist) return false;
return (await navigator.storage.persisted?.()) || (await navigator.storage.persist());
} catch {
return false;
}
}
+31
View File
@@ -0,0 +1,31 @@
import { getOfflineStore } from "./db";
import { buildOrderView, isBundleStale, type OrderView } from "./bundle-core";
import { getOfflineState, syncNow, whenReady } from "./outbox";
import { ctxKeyOf } from "./types";
/**
* Data access of the mobile client views (lane L7, Spec §23.2): online a fresh bundle is pulled
* first (server data), offline the local bundle is used. Either way the view contains the own
* unsent changes (optimistic), so the user sees what they entered.
*/
export type OrdersRead = { ready: boolean; orders: OrderView[]; syncedAt: string | null; stale: boolean };
export async function readOrders(opts: { refresh?: boolean } = {}): Promise<OrdersRead> {
if (!(await whenReady())) return { ready: false, orders: [], syncedAt: null, stale: true };
if (opts.refresh && navigator.onLine) await syncNow({ pull: true }).catch(() => undefined);
const { ctx, maxDays, lastPullAt } = getOfflineState();
if (!ctx) return { ready: false, orders: [], syncedAt: null, stale: true };
const store = await getOfflineStore();
const key = ctxKeyOf(ctx);
const [records, ops] = await Promise.all([store.listBundle(key), store.listOps(key)]);
const orders = records
.map((r) => buildOrderView(r, ops))
.sort((a, b) => (a.plannedStart ?? "9999").localeCompare(b.plannedStart ?? "9999") || a.number.localeCompare(b.number));
return { ready: true, orders, syncedAt: lastPullAt, stale: isBundleStale(lastPullAt, new Date(), maxDays) };
}
export async function readOrder(workOrderId: string, opts: { refresh?: boolean } = {}): Promise<{ read: OrdersRead; order: OrderView | null }> {
const read = await readOrders(opts);
return { read, order: read.orders.find((o) => o.id === workOrderId) ?? null };
}
+174
View File
@@ -0,0 +1,174 @@
import type { SyncOperationInput, SyncResponse } from "@/lib/sync/envelope";
import { ctxKeyOf, type BlobEntry, type BlobKind, type OfflineContext, type OfflineStore, type OutboxEntry, type OutboxError } from "./types";
import {
applyResults,
applyTransportFailure,
backoffMs,
blobRef,
createEntry,
MAX_BATCH,
recoverSending,
selectBatch,
selectUploads,
type QueuedOpInput,
} from "./outbox-core";
/**
* One synchronisation pass over the outbox of a context (lane L7). Environment-free: storage and
* network are injected, so the same code runs in the browser (outbox.ts: IndexedDB + fetch) and in
* the tests (memory store + in-process applyOperations).
*/
export type BatchOutcome = { ok: true; response: SyncResponse } | { ok: false; error: "network" | "unauthorized" | "internal" };
export type UploadOutcome = { ok: true; documentId: string } | { ok: false; error: "network" | "unauthorized" | "invalid" | "forbidden" | "not_found" | "internal" };
export type Transport = {
sendBatch(deviceId: string, operations: SyncOperationInput[]): Promise<BatchOutcome>;
upload(blob: BlobEntry): Promise<UploadOutcome>;
};
export type EngineDeps = {
store: OfflineStore;
ctx: OfflineContext;
transport: Transport;
deviceId: string;
now?: () => Date;
random?: () => number;
maxBatch?: number;
/** safety limit of batch rounds per pass */
maxRounds?: number;
};
export type PassResult = {
rounds: number;
sent: number;
applied: number;
conflicts: number;
rejected: number;
uploaded: number;
uploadFailed: number;
/** transport-level stop reason of the pass */
stopped: null | "network" | "unauthorized" | "internal";
/** client id → server id of this pass */
idMap: Record<string, string>;
};
export async function enqueueOp(store: OfflineStore, ctx: OfflineContext, op: QueuedOpInput, clientOpId: string, now = new Date()): Promise<OutboxEntry> {
const existing = await store.listOps(ctxKeyOf(ctx));
const entry = createEntry(ctx, op, { clientOpId, now, existing });
await store.putOp(entry);
// link queued blobs to the op waiting for them
for (const ref of entry.blobRefs) {
const blob = await store.getBlob(ref);
if (!blob || blob.ctxKey !== entry.ctxKey) continue;
// a new op on a finally failed upload gets one fresh upload attempt
const rearm = blob.status === "failed" && blob.nextAttemptAt === null ? { status: "pending" as const, lastError: null, attempts: 0 } : {};
await store.putBlob({ ...blob, ...rearm, opClientOpId: entry.clientOpId, updatedAt: now.toISOString() });
}
return entry;
}
export type BlobInput = { clientId: string; workOrderId: string; kind: BlobKind; blob: Blob; preview?: Blob | null; fileName: string };
/** Stores a blob for upload; returns the reference to put into the op payload (`documentId`). */
export async function enqueueBlob(store: OfflineStore, ctx: OfflineContext, input: BlobInput, now = new Date()): Promise<string> {
const iso = now.toISOString();
await store.putBlob({
clientId: input.clientId,
ctxKey: ctxKeyOf(ctx),
tenantId: ctx.tenantId,
userId: ctx.userId,
workOrderId: input.workOrderId,
kind: input.kind,
blob: input.blob,
preview: input.preview ?? null,
fileName: input.fileName,
size: input.blob.size + (input.preview?.size ?? 0),
status: "pending",
documentId: null,
attempts: 0,
lastError: null,
nextAttemptAt: null,
opClientOpId: null,
createdAt: iso,
updatedAt: iso,
});
return blobRef(input.clientId);
}
const FINAL_UPLOAD_ERRORS = new Set(["invalid", "forbidden", "not_found"]);
export async function runSyncPass(deps: EngineDeps): Promise<PassResult> {
const { store, ctx, transport, deviceId } = deps;
const now = deps.now ?? (() => new Date());
const random = deps.random ?? Math.random;
const key = ctxKeyOf(ctx);
const res: PassResult = { rounds: 0, sent: 0, applied: 0, conflicts: 0, rejected: 0, uploaded: 0, uploadFailed: 0, stopped: null, idMap: {} };
for (const e of recoverSending(await store.listOps(key), now())) await store.putOp(e);
const maxRounds = deps.maxRounds ?? 20;
while (res.rounds < maxRounds) {
const ops = await store.listOps(key);
const blobs = await store.listBlobs(key);
// 1. uploads before the ops that reference them
for (const blob of selectUploads(ops, blobs, now())) {
await store.putBlob({ ...blob, status: "uploading", updatedAt: now().toISOString() });
const up = await transport.upload(blob);
const iso = now().toISOString();
if (up.ok) {
await store.putBlob({ ...blob, status: "uploaded", documentId: up.documentId, lastError: null, nextAttemptAt: null, updatedAt: iso });
res.uploaded++;
continue;
}
const attempts = blob.attempts + 1;
const error: OutboxError = { code: up.error === "unauthorized" || up.error === "network" ? up.error : "upload", message: up.error };
if (FINAL_UPLOAD_ERRORS.has(up.error)) {
await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: null, updatedAt: iso });
res.uploadFailed++;
// the waiting op can never be sent → rejected with a clear reason
for (const op of ops.filter((o) => o.status === "queued" && o.blobRefs.includes(blob.clientId))) {
await store.putOp({ ...op, status: "rejected", lastError: { code: "upload", message: up.error }, updatedAt: iso, nextAttemptAt: null });
res.rejected++;
}
} else {
await store.putBlob({ ...blob, status: "failed", attempts, lastError: error, nextAttemptAt: new Date(now().getTime() + backoffMs(attempts, random)).toISOString(), updatedAt: iso });
if (up.error === "network" || up.error === "unauthorized") {
res.stopped = up.error;
return res;
}
}
}
// 2. next batch
const current = await store.listOps(key);
const batch = selectBatch(current, await store.listBlobs(key), now(), deps.maxBatch ?? MAX_BATCH);
if (batch.entries.length === 0) break;
res.rounds++;
for (const e of batch.entries) await store.putOp({ ...e, status: "sending", updatedAt: now().toISOString() });
const outcome = await transport.sendBatch(deviceId, batch.operations);
if (!outcome.ok) {
const failed = applyTransportFailure(batch.entries, { code: outcome.error === "internal" ? "internal" : outcome.error }, now(), random);
for (const e of failed) await store.putOp(e);
res.stopped = outcome.error;
return res;
}
res.sent += batch.entries.length;
const { changed, idMap } = applyResults(current, batch.entries, outcome.response.results, now(), random);
Object.assign(res.idMap, idMap);
for (const e of changed) {
await store.putOp(e);
if (!batch.entries.some((b) => b.clientOpId === e.clientOpId)) continue;
if (e.status === "applied") {
res.applied++;
// uploaded bytes are no longer needed once the referencing op is applied
for (const ref of e.blobRefs) await store.deleteBlob(ref);
} else if (e.status === "conflict") res.conflicts++;
else if (e.status === "rejected") res.rejected++;
}
}
return res;
}
+169
View File
@@ -0,0 +1,169 @@
import type { SyncOpResult, SyncOpType } from "@/lib/sync/envelope";
/**
* Local offline storage model (lane L7, Spec §23, ARCHITEKTUR §4.6). Client-safe, no browser APIs:
* shared by the IndexedDB adapter (db.ts), the in-memory adapter (memory-store.ts, tests) and the
* pure outbox/bundle logic (outbox-core.ts, bundle-core.ts).
*
* Every record carries tenantId + userId and is addressed through `ctxKey` — data of one
* tenant/user context is never returned for another one.
*/
export type OfflineContext = { tenantId: string; userId: string };
export const ctxKeyOf = (c: OfflineContext): string => `${c.tenantId}:${c.userId}`;
/** Local lifecycle of an op (Spec §23.4 "Synchronisationsstatus"). */
export type OutboxStatus = "queued" | "sending" | "applied" | "conflict" | "rejected";
export type OutboxError = { code: NonNullable<SyncOpResult["errorCode"]> | "network" | "unauthorized" | "upload"; message?: string };
export type OutboxEntry = {
clientOpId: string;
ctxKey: string;
tenantId: string;
userId: string;
opType: SyncOpType;
payload: Record<string, unknown>;
entityType?: string;
entityId?: string;
baseVersion?: number;
/** baseVersion is taken from the server result of the preceding op of the same order (own chain). */
chainedBase?: boolean;
/** work order the op belongs to — ops of one order are sent strictly in order */
workOrderId: string | null;
/** monotonic local sequence (creation order) */
seq: number;
status: OutboxStatus;
attempts: number;
lastError: OutboxError | null;
clientCreatedAt: string;
updatedAt: string;
/** earliest time of the next send attempt (exponential backoff) */
nextAttemptAt: string | null;
appliedAt: string | null;
/** client ids of queued blobs referenced by the payload (documentId: "blob:<clientId>") */
blobRefs: string[];
result: SyncOpResult | null;
/** the result was already shown inline to the user (immediate online submit) */
acknowledged?: boolean;
};
export type BlobKind = "photo" | "voice_note";
export type BlobStatus = "pending" | "uploading" | "uploaded" | "failed";
export type BlobEntry = {
clientId: string;
ctxKey: string;
tenantId: string;
userId: string;
workOrderId: string;
kind: BlobKind;
blob: Blob;
preview: Blob | null;
fileName: string;
size: number;
status: BlobStatus;
documentId: string | null;
attempts: number;
lastError: OutboxError | null;
nextAttemptAt: string | null;
/** op waiting for this upload */
opClientOpId: string | null;
createdAt: string;
updatedAt: string;
};
/** One order of GET /api/v1/field/bundle as stored locally (server snapshot, never mutated optimistically). */
export type BundleOrderData = {
id: string;
number: string;
title: string;
status: string;
statusGroup: string;
priority: string;
isEmergency: boolean;
plannedStart: string | null;
plannedEnd: string | null;
version: number;
updatedAt?: string;
externalOrderNumber?: string | null;
description?: string | null;
scope?: string | null;
technicianNotes?: string | null;
signatureRequired?: boolean;
orderType?: { name: string } | null;
customer: {
id?: string;
companyName: string | null;
firstName: string | null;
lastName: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
phone?: string | null;
mobile?: string | null;
email?: string | null;
};
contact?: { name: string; role: string | null; phone: string | null; mobile: string | null; email: string | null } | null;
site?: {
id: string;
name: string | null;
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
phone?: string | null;
onSiteContact?: string | null;
accessNotes?: string | null;
parkingNotes?: string | null;
safetyNotes?: string | null;
technicalNotes?: string | null;
contact?: { name: string; role: string | null; phone: string | null; mobile: string | null; email: string | null } | null;
} | null;
checklistItems: Array<{ id: string; label: string; required: boolean; requiresPhoto: boolean; checked: boolean; checkedAt: string | null; comment: string | null }>;
photoRequirements: Array<{ id: string; key: string; label: string; _count: { photos: number } }>;
materialPlans: Array<{ id: string; name: string; articleNumber: string | null; plannedQuantity: number | string; unit: string; notes: string | null }>;
materialUsages: Array<{
id: string;
materialPlanId: string | null;
name: string | null;
articleNumber: string | null;
actualQuantity: number | string;
unit: string;
usageStatus: string;
deviationReason: string | null;
notes: string | null;
clientId: string | null;
}>;
documents: Array<{ id: string; title: string | null; fileName: string; category: string; mimeType: string; fileSize: number; checksum?: string | null; version: number; lineageId: string }>;
siteHistory: Array<{ reportId: string; reportType: string; reportDate: string; workOrderId: string; workOrderNumber: string; workOrderTitle: string; pdfHref: string }>;
};
export type BundleRecord = { ctxKey: string; workOrderId: string; data: BundleOrderData; syncedAt: string };
export type DraftRecord = { ctxKey: string; key: string; value: unknown; updatedAt: string };
/** Storage adapter. IndexedDB in the browser (db.ts), in-memory in tests (memory-store.ts). */
export interface OfflineStore {
// outbox
putOp(entry: OutboxEntry): Promise<void>;
listOps(ctxKey: string): Promise<OutboxEntry[]>;
deleteOp(clientOpId: string): Promise<void>;
// blobs
putBlob(entry: BlobEntry): Promise<void>;
getBlob(clientId: string): Promise<BlobEntry | null>;
listBlobs(ctxKey: string): Promise<BlobEntry[]>;
deleteBlob(clientId: string): Promise<void>;
// bundle (server snapshot per order)
replaceBundle(ctxKey: string, records: BundleRecord[]): Promise<void>;
listBundle(ctxKey: string): Promise<BundleRecord[]>;
// meta + drafts (key/value per context)
getMeta<T = unknown>(ctxKey: string, key: string): Promise<T | null>;
setMeta(ctxKey: string, key: string, value: unknown): Promise<void>;
deleteMeta(ctxKey: string, key: string): Promise<void>;
// contexts
listContexts(): Promise<string[]>;
clearContext(ctxKey: string): Promise<void>;
}