From 9f73cdae493acff31484fa0db5b7713127441f15 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 17:19:43 +0200 Subject: [PATCH 1/6] L7 Offline & PWA: Outbox-Kern, IndexedDB-Store, Sync-Engine und Bundle-Logik Co-Authored-By: Claude Opus 5 --- scripts/test-offline-core.ts | 379 ++++++++++++++++++++++++++++ src/lib/offline/bundle-core.ts | 171 +++++++++++++ src/lib/offline/db.ts | 136 ++++++++++ src/lib/offline/doc-cache.ts | 54 ++++ src/lib/offline/drafts.ts | 42 ++++ src/lib/offline/ids.ts | 28 +++ src/lib/offline/memory-store.ts | Bin 0 -> 2798 bytes src/lib/offline/outbox-core.ts | 350 ++++++++++++++++++++++++++ src/lib/offline/outbox.ts | 425 ++++++++++++++++++++++++++++++++ src/lib/offline/prefetch.ts | 122 +++++++++ src/lib/offline/read.ts | 31 +++ src/lib/offline/sync-engine.ts | 174 +++++++++++++ src/lib/offline/types.ts | 169 +++++++++++++ 13 files changed, 2081 insertions(+) create mode 100644 scripts/test-offline-core.ts create mode 100644 src/lib/offline/bundle-core.ts create mode 100644 src/lib/offline/db.ts create mode 100644 src/lib/offline/doc-cache.ts create mode 100644 src/lib/offline/drafts.ts create mode 100644 src/lib/offline/ids.ts create mode 100644 src/lib/offline/memory-store.ts create mode 100644 src/lib/offline/outbox-core.ts create mode 100644 src/lib/offline/outbox.ts create mode 100644 src/lib/offline/prefetch.ts create mode 100644 src/lib/offline/read.ts create mode 100644 src/lib/offline/sync-engine.ts create mode 100644 src/lib/offline/types.ts diff --git a/scripts/test-offline-core.ts b/scripts/test-offline-core.ts new file mode 100644 index 0000000..4a41e40 --- /dev/null +++ b/scripts/test-offline-core.ts @@ -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 => ({ clientOpId: op.clientOpId, status: "applied", ...extra }); + +function order(id: string, extra: Partial = {}): 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:"); + 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, 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); +}); diff --git a/src/lib/offline/bundle-core.ts b/src/lib/offline/bundle-core.ts new file mode 100644 index 0000000..0e08616 --- /dev/null +++ b/src/lib/offline/bundle-core.ts @@ -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>(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(v: T): T { + return typeof structuredClone === "function" ? structuredClone(v) : (JSON.parse(JSON.stringify(v)) as T); +} diff --git a/src/lib/offline/db.ts b/src/lib/offline/db.ts new file mode 100644 index 0000000..17c4638 --- /dev/null +++ b/src/lib/offline/db.ts @@ -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 = (r: IDBRequest) => + new Promise((resolve, reject) => { + r.onsuccess = () => resolve(r.result); + r.onerror = () => reject(r.error); + }); + +const done = (tx: IDBTransaction) => + new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error ?? new Error("transaction aborted")); + }); + +function open(): Promise { + 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 | null = null; + const db = () => (dbPromise ??= open().catch((err) => { + dbPromise = null; + throw err; + })); + + async function run(names: StoreName | StoreName[], mode: IDBTransactionMode, fn: (tx: IDBTransaction) => Promise | T): Promise { + const tx = (await db()).transaction(names, mode); + const finished = done(tx); + const value = await fn(tx); + await finished; + return value; + } + + const byCtx = (name: StoreName, ctxKey: string) => run(name, "readonly", (tx) => req(tx.objectStore(name).index("ctxKey").getAll(ctxKey)) as Promise); + + return { + putOp: (entry) => run("outbox", "readwrite", (tx) => void tx.objectStore("outbox").put(entry)), + listOps: async (ctxKey) => (await byCtx("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("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("bundle", ctxKey), + + getMeta: async (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(); + for (const name of STORES) { + // unique index keys = contexts present in this store + await new Promise((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 { + 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; +} diff --git a/src/lib/offline/doc-cache.ts b/src/lib/offline/doc-cache.ts new file mode 100644 index 0000000..f18eb2e --- /dev/null +++ b/src/lib/offline/doc-cache.ts @@ -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 = 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(docs: T[]): T[] { + const seen = new Set(); + 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; + }); +} diff --git a/src/lib/offline/drafts.ts b/src/lib/offline/drafts.ts new file mode 100644 index 0000000..993a143 --- /dev/null +++ b/src/lib/offline/drafts.ts @@ -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:` or `report::daily`. + */ + +const metaKey = (key: string) => `draft:${key}`; + +export async function loadDraft(key: string): Promise { + if (!(await whenReady())) return null; + const ctx = getOfflineState().ctx; + if (!ctx) return null; + try { + return await (await getOfflineStore()).getMeta(ctxKeyOf(ctx), metaKey(key)); + } catch { + return null; + } +} + +export async function saveDraft(key: string, value: unknown): Promise { + 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 { + const ctx = getOfflineState().ctx; + if (!ctx) return; + try { + await (await getOfflineStore()).deleteMeta(ctxKeyOf(ctx), metaKey(key)); + } catch { + /* ignore */ + } +} diff --git a/src/lib/offline/ids.ts b/src/lib/offline/ids.ts new file mode 100644 index 0000000..07a8a26 --- /dev/null +++ b/src/lib/offline/ids.ts @@ -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"; + } +} diff --git a/src/lib/offline/memory-store.ts b/src/lib/offline/memory-store.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc891f52345550a7e817ce90ddffae6b63a26f70 GIT binary patch literal 2798 zcmaJ@&2Hm15Z<$&Vqh$=RNyE*C+h^rZVzd(4bTMbAute18f^$mq)Jjr48y=aM4zxv z(iu{cWk=aute@fhd_SY8x-r&+-!uqYxUNk0tM+!Yfa_Kl75<62vBd&z%d%1$@4T@{ zcdgINYY2oLl-ATRPtQ1qn_tXkXD25!IDs2|T4QbO22|ZtSflpRE2F`b2l?Q?AOAw@ zuz+j>{t3aO^A3c|t!lhG9NIS*xI)4&?Baqwd7TIugl8XFss*(V2;D$%PNQ?B!utXgy+ zErRp5&3$XJIL#}gsc&Z>r#{d+`qiI2)Gdw5kpx!(Hh}1OJc}h-)H!wJWQ{Zg+!_ei(_QA?W80S+KMH#&!=^|y^6-P=vly#OGT zQinc1PV-99``hM*Oct2$!UIqMy9H(L(^bk*r0W{oXY49Jq-n~@pMz9IvPw%;d9*^9 z(6>or+d(n$GD#h|UkJH?EW}7^?H@RWjDM4~mW>EqO|?^Cg&t`@RqT7=2I32T%xoVG zL6|VZ$Ecj0h2;|+rfN12`Sv0hG zVvl#o$2*5FjvMNsk7uR;h?E~!IZEH@VyePaPG;(h(H>vD6Z=#hcDU&`{T=%5OVX`t zRx5v}MGQ6k-^6(9CA_a)DvLt&3va-DbS~p_hb9`G?EN!!ave@~3g%jZNE&Lc-#rpY zcpIO(rcz$aKg^SdUwZuw^5C7$XI2w7%`23)!*Eb>FT&BN4~mwWS#>}qph_5^8Gj1` zjK}sq06S#LLZ$#+%+-T+kZXEb0QAo5>U8Fd(;d_bF!T5$+*YyRI(1%J@BUW)NsPY6 Uk3>X>?vnRhgpcU%Vz!(84~6w|qyPW_ literal 0 HcmV?d00001 diff --git a/src/lib/offline/outbox-core.ts b/src/lib/offline/outbox-core.ts new file mode 100644 index 0000000..affe083 --- /dev/null +++ b/src/lib/offline/outbox-core.ts @@ -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:"`) + * - 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(["internal", "network"]); + +export type QueuedOpInput = { + opType: SyncOpType; + payload: Record; + 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(); + 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): 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) => 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(); + 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 | 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; +} + +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(); + 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, idMap: Record): Record { + 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; +} + +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 } { + const iso = now.toISOString(); + const byOp = new Map(results.map((r) => [r.clientOpId, r])); + const changed = new Map(); + const idMap: Record = {}; + const versions = new Map(); + + 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) => 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): 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"; + } +} diff --git a/src/lib/offline/outbox.ts b/src/lib/offline/outbox.ts new file mode 100644 index 0000000..6dd9f40 --- /dev/null +++ b/src/lib/offline/outbox.ts @@ -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; +}; + +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) { + 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 { + 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 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((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 { + 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(key, META_LAST_PULL), + lastSyncAt: await store.getMeta(key, "lastSyncAt"), + }); + readyWaiters.forEach((w) => w()); + readyWaiters = []; + await refreshSummary(); + void requestPersistence(); +} + +export async function refreshSummary(): Promise { + 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 | 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 { + 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) { + 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 } }).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 { + const outcome = await browserTransport.sendBatch(deviceId(), [ + { clientOpId, opType: op.opType, payload: op.payload as Record, 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 { + 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 }, 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 & { 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 }); +} diff --git a/src/lib/offline/prefetch.ts b/src/lib/offline/prefetch.ts new file mode 100644 index 0000000..a195ada --- /dev/null +++ b/src/lib/offline/prefetch.ts @@ -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 { + 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 { + 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 { + 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> { + 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 { + 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 { + 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 { + try { + if (!navigator.storage?.persist) return false; + return (await navigator.storage.persisted?.()) || (await navigator.storage.persist()); + } catch { + return false; + } +} diff --git a/src/lib/offline/read.ts b/src/lib/offline/read.ts new file mode 100644 index 0000000..6fc0875 --- /dev/null +++ b/src/lib/offline/read.ts @@ -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 { + 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 }; +} diff --git a/src/lib/offline/sync-engine.ts b/src/lib/offline/sync-engine.ts new file mode 100644 index 0000000..e070463 --- /dev/null +++ b/src/lib/offline/sync-engine.ts @@ -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; + upload(blob: BlobEntry): Promise; +}; + +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; +}; + +export async function enqueueOp(store: OfflineStore, ctx: OfflineContext, op: QueuedOpInput, clientOpId: string, now = new Date()): Promise { + 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 { + 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 { + 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; +} diff --git a/src/lib/offline/types.ts b/src/lib/offline/types.ts new file mode 100644 index 0000000..3828eb3 --- /dev/null +++ b/src/lib/offline/types.ts @@ -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 | "network" | "unauthorized" | "upload"; message?: string }; + +export type OutboxEntry = { + clientOpId: string; + ctxKey: string; + tenantId: string; + userId: string; + opType: SyncOpType; + payload: Record; + 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:") */ + 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; + listOps(ctxKey: string): Promise; + deleteOp(clientOpId: string): Promise; + // blobs + putBlob(entry: BlobEntry): Promise; + getBlob(clientId: string): Promise; + listBlobs(ctxKey: string): Promise; + deleteBlob(clientId: string): Promise; + // bundle (server snapshot per order) + replaceBundle(ctxKey: string, records: BundleRecord[]): Promise; + listBundle(ctxKey: string): Promise; + // meta + drafts (key/value per context) + getMeta(ctxKey: string, key: string): Promise; + setMeta(ctxKey: string, key: string, value: unknown): Promise; + deleteMeta(ctxKey: string, key: string): Promise; + // contexts + listContexts(): Promise; + clearContext(ctxKey: string): Promise; +} From 4dd330b9fd7325a2cb072b0cf94772f30e899ebd Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 17:20:20 +0200 Subject: [PATCH 2/6] L7 Offline & PWA: Service Worker (Static/Seiten/Dokument-Cache) und Manifest start_url /m Co-Authored-By: Claude Opus 5 --- public/site.webmanifest | 2 +- public/sw.js | 180 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 181 insertions(+), 1 deletion(-) create mode 100644 public/sw.js diff --git a/public/site.webmanifest b/public/site.webmanifest index cfe1191..31f4b57 100644 --- a/public/site.webmanifest +++ b/public/site.webmanifest @@ -3,7 +3,7 @@ "short_name": "Craftvia", "description": "Handwerk. Digital auf Kurs.", "lang": "de", - "start_url": "/dashboard", + "start_url": "/m", "scope": "/", "theme_color": "#082E5B", "background_color": "#F3F5F7", diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..bc330b7 --- /dev/null +++ b/public/sw.js @@ -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/ LRU limit 300 MB); documents not in the cache go to the network + * /files/ + * 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; From f0620f9c3b567fd00798c8f3955ada6f4451d961 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 17:20:44 +0200 Subject: [PATCH 3/6] L7 Offline & PWA: Sync-Seite, Offline-Ansicht, Sync-Badge, Installationshinweis, Logout-Schutz Co-Authored-By: Claude Opus 5 --- messages/de/offline.json | 171 +++++ messages/en/offline.json | 171 +++++ src/app/(field)/m/offline/layout.tsx | 7 + src/app/(field)/m/offline/page.tsx | 17 + src/app/(field)/m/sync/layout.tsx | 7 + src/app/(field)/m/sync/page.tsx | 20 +- src/components/offline/format.ts | 22 + src/components/offline/hooks.ts | 54 ++ src/components/offline/install-hint.tsx | 72 +++ src/components/offline/logout-form.tsx | 79 +++ .../offline/offline-runtime-client.tsx | 94 +++ src/components/offline/offline-runtime.tsx | 16 + src/components/offline/offline-view.tsx | 590 ++++++++++++++++++ src/components/offline/sync-badge.tsx | 39 ++ src/components/offline/sync-panel.tsx | 287 +++++++++ 15 files changed, 1633 insertions(+), 13 deletions(-) create mode 100644 messages/de/offline.json create mode 100644 messages/en/offline.json create mode 100644 src/app/(field)/m/offline/layout.tsx create mode 100644 src/app/(field)/m/offline/page.tsx create mode 100644 src/app/(field)/m/sync/layout.tsx create mode 100644 src/components/offline/format.ts create mode 100644 src/components/offline/hooks.ts create mode 100644 src/components/offline/install-hint.tsx create mode 100644 src/components/offline/logout-form.tsx create mode 100644 src/components/offline/offline-runtime-client.tsx create mode 100644 src/components/offline/offline-runtime.tsx create mode 100644 src/components/offline/offline-view.tsx create mode 100644 src/components/offline/sync-badge.tsx create mode 100644 src/components/offline/sync-panel.tsx diff --git a/messages/de/offline.json b/messages/de/offline.json new file mode 100644 index 0000000..3e040f4 --- /dev/null +++ b/messages/de/offline.json @@ -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." + } +} diff --git a/messages/en/offline.json b/messages/en/offline.json new file mode 100644 index 0000000..b4eed23 --- /dev/null +++ b/messages/en/offline.json @@ -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." + } +} diff --git a/src/app/(field)/m/offline/layout.tsx b/src/app/(field)/m/offline/layout.tsx new file mode 100644 index 0000000..6f19e40 --- /dev/null +++ b/src/app/(field)/m/offline/layout.tsx @@ -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}; +} diff --git a/src/app/(field)/m/offline/page.tsx b/src/app/(field)/m/offline/page.tsx new file mode 100644 index 0000000..6e52cec --- /dev/null +++ b/src/app/(field)/m/offline/page.tsx @@ -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 ( +
+

{t("title")}

+ +
+ ); +} diff --git a/src/app/(field)/m/sync/layout.tsx b/src/app/(field)/m/sync/layout.tsx new file mode 100644 index 0000000..bfb3652 --- /dev/null +++ b/src/app/(field)/m/sync/layout.tsx @@ -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}; +} diff --git a/src/app/(field)/m/sync/page.tsx b/src/app/(field)/m/sync/page.tsx index 9a81ea5..18afede 100644 --- a/src/app/(field)/m/sync/page.tsx +++ b/src/app/(field)/m/sync/page.tsx @@ -1,23 +1,17 @@ import { getTranslations } from "next-intl/server"; -import { OnlineBadge } from "@/components/field/online-badge"; -import { card } from "@/components/field/ui"; +import { SyncPanel } from "@/components/offline/sync-panel"; /** - * PLACEHOLDER (lane L4) — `/m/sync` belongs to lane L7 (Offline/PWA), which replaces this page - * with the outbox status, errors and conflicts. Until then it shows the connection state and that - * ops are sent immediately (src/lib/field/client-ops.ts). + * `/m/sync` (lane L7, Spec §22/§23.4, US-012): connection, last sync, pending changes and uploads + * with progress, errors/conflicts in plain language (retry / discard), storage usage, reset. The + * data lives on the device (IndexedDB), so the panel is a client component. */ -export default async function SyncPlaceholderPage() { - const t = await getTranslations("field.sync"); +export default async function SyncPage() { + const t = await getTranslations("offline.sync"); return (

{t("title")}

-
-

{t("status")}

- -

{t("immediate")}

-

{t("offlineHint")}

-
+
); } diff --git a/src/components/offline/format.ts b/src/components/offline/format.ts new file mode 100644 index 0000000..415d326 --- /dev/null +++ b/src/components/offline/format.ts @@ -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)); +} diff --git a/src/components/offline/hooks.ts b/src/components/offline/hooks.ts new file mode 100644 index 0000000..f6bafdf --- /dev/null +++ b/src/components/offline/hooks.ts @@ -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(key: string, initial: T): [T, (next: T) => void, () => Promise, boolean] { + const [value, setValue] = useState(initial); + const [restored, setRestored] = useState(false); + const timer = useRef | null>(null); + const touched = useRef(false); + + useEffect(() => { + let cancelled = false; + touched.current = false; + loadDraft(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]; +} diff --git a/src/components/offline/install-hint.tsx b/src/components/offline/install-hint.tsx new file mode 100644 index 0000000..6a1dfb8 --- /dev/null +++ b/src/components/offline/install-hint.tsx @@ -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; 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(noSubscribe, detect, () => "unknown"); + const [prompt, setPrompt] = useState(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 ( +
+

+ + {t("title")} +

+ {mode === "installed" || installed ? ( +

+ + {t("installed")} +

+ ) : prompt ? ( + + ) : ( +

{mode === "ios" ? t("ios") : t("other")}

+ )} +
+ ); +} diff --git a/src/components/offline/logout-form.tsx b/src/components/offline/logout-form.tsx new file mode 100644 index 0000000..ef35e01 --- /dev/null +++ b/src/components/offline/logout-form.tsx @@ -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; label: string }) { + const t = useTranslations("offline.logout"); + const form = useRef(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 ( +
{ + if (proceed.current) return; + e.preventDefault(); + const n = await unsentCount().catch(() => 0); + if (n > 0) { + setUnsent(n); + return; + } + await finish(); + }} + > + {unsent > 0 ? ( +
+

+ + {t("warning", { count: unsent })} +

+ + + +
+ ) : ( + + )} +
+ ); +} diff --git a/src/components/offline/offline-runtime-client.tsx b/src/components/offline/offline-runtime-client.tsx new file mode 100644 index 0000000..5a99028 --- /dev/null +++ b/src/components/offline/offline-runtime-client.tsx @@ -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(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 | 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 ( + <> + + {waiting && ( +
+ +

{t("available")}

+ +
+ )} + + ); +} diff --git a/src/components/offline/offline-runtime.tsx b/src/components/offline/offline-runtime.tsx new file mode 100644 index 0000000..f46c60b --- /dev/null +++ b/src/components/offline/offline-runtime.tsx @@ -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 ; +} diff --git a/src/components/offline/offline-view.tsx b/src/components/offline/offline-view.tsx new file mode 100644 index 0000000..e1412a1 --- /dev/null +++ b/src/components/offline/offline-view.tsx @@ -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/` 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(initialOrderId); + const [data, setData] = useState(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 ( +

+ + {t("loading")} +

+ ); + } + + const order = orderId ? data.orders.find((o) => o.id === orderId) ?? null : null; + + return ( +
+ {online ? ( +
+ + + {t("onlineHint")}{" "} + + {t("backOnline")} + + +
+ ) : ( +

+ + {t("offlineHint")} +

+ )} + {data.syncedAt &&

{t("savedAt", { time: fmtDateTimeShort(data.syncedAt, locale) })}

} + {data.stale && data.orders.length > 0 && ( +

+ + {tSync("stale", { days: s.maxDays })} +

+ )} + + {orderId ? ( + <> + + {order ? :

{t("notFound")}

} + + ) : !data.ready ? ( +

{t("notReady")}

+ ) : data.orders.length === 0 ? ( +

{t("empty")}

+ ) : ( + <> +

{t("count", { count: data.orders.length })}

+
    + {data.orders.map((o) => ( +
  • + open(o.id)} /> +
  • + ))} +
+ + )} +
+ ); +} + +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 && ( +

+ + {t("pending", { count: order.local.pendingOps })} +

+ )} + {(order.local.conflict || order.local.rejected) && ( + + + {order.local.conflict ? t("conflict") : t("rejected")} + + )} + + ); +} + +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 ( +
[0])?.edge)}> +
+ {order.number} + +
+

{order.title}

+

{customerName(order.customer)}

+
+
+ +
{window ?? t("noDate")}
+
+ {addr && ( +
+ +
{addr}
+
+ )} +
+
+ +
+ +
+ ); +} + +type Feedback = { kind: "queued" | "saved" | "error"; text: string } | null; + +function useFeedback() { + const t = useTranslations("offline.view"); + const tf = useTranslations("field"); + const [feedback, setFeedback] = useState(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 ( +

+ + {feedback.text} +

+ ); +} + +function Section({ title, icon: Icon, children }: { title: string; icon: typeof Clock; children: React.ReactNode }) { + return ( +
+

+ + {title} +

+
{children}
+
+ ); +} + +function OrderDetail({ order, onChanged }: { order: OrderView; onChanged: () => void }) { + const t = useTranslations("offline.view"); + const tf = useTranslations("field"); + const locale = useLocale(); + const [cached, setCached] = useState>(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 ( +
+
+
+ {order.number} + +
+

{order.title}

+

{fmtWindow(dateOrNull(order.plannedStart), dateOrNull(order.plannedEnd), locale) ?? t("noDate")}

+ + +
+ + {order.technicianNotes && ( +
+

{order.technicianNotes}

+
+ )} + +
+ {order.site?.name &&

{order.site.name}

} + {siteAddress &&

{siteAddress}

} +
+ {hints + .filter(([, v]) => !!v) + .map(([label, value]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ +
+

{customerName(order.customer)}

+ {contact && ( +

+ {t("contact")}: {contact.name} + {contact.role ? ` (${contact.role})` : ""} +

+ )} + {phone && ( + + + {t("call")} + + )} +
+ + {(order.scope || order.description) && ( +
+ {order.scope &&

{order.scope}

} + {order.description &&

{order.description}

} +
+ )} + +
+ {order.checklistItems.length === 0 ?

{t("checklistEmpty")}

: } +
+ +
+ + {order.photoRequirements.length > 0 && ( +
    + {order.photoRequirements.map((r) => ( +
  • + {r.label} + 0 ? "text-[var(--ok)]" : "text-muted-foreground")}>{r._count.photos > 0 ? t("photoDone") : t("photoMissing")} +
  • + ))} +
+ )} + {order.local.photos.length > 0 && ( +
    + {order.local.photos.map((p) => ( +
  • + + {fmtDateTimeShort(p.createdAt, locale)} {p.phase ? `· ${tf(`photos.phase.${p.phase}`)}` : ""} {p.pending && · {t("pendingBadge")}} +
  • + ))} +
+ )} +
+ +
+ + {order.local.notes.map((n) => ( +
+

+ {tf(`notes.kind.${n.kind}`)} · {fmtDateTimeShort(n.createdAt, locale)} {n.pending && · {t("pendingBadge")}} +

+

{n.text}

+
+ ))} +
+ + {order.materialPlans.length > 0 && ( +
+

{t("materialsProgress", { done: recorded, total: order.materialPlans.length })}

+
+ )} + +
+ {order.documents.length === 0 ? ( +

{t("noDocuments")}

+ ) : ( + + )} +
+ +
+ {order.siteHistory.length === 0 ? ( +

{t("noHistory")}

+ ) : ( +
    + {order.siteHistory.map((h) => ( +
  • +

    + {h.workOrderNumber} · {tf(`detail.reportType.${h.reportType}`)} +

    +

    + {fmtDateTimeShort(h.reportDate, locale)} · {h.workOrderTitle} +

    +
  • + ))} +
+ )} +
+
+ ); +} + +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(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 = { 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 ( +
+

{t("time")}

+ {actions.map((a, i) => ( + + ))} + {showCompleteHint &&

{t("completeOnline")}

} + +
+ ); +} + +function Checklist({ order, onChanged }: { order: OrderView; onChanged: () => void }) { + const t = useTranslations("offline.view"); + const { feedback, report } = useFeedback(); + const [busy, setBusy] = useState(null); + return ( + <> +
    + {order.checklistItems.map((item) => ( +
  • + +
  • + ))} +
+ + + ); +} + +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 ( +
{ + 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(); + } + }} + > +
+ {NOTE_KINDS.slice(0, 4).map((k) => ( + + ))} +
+