// Synchronisation: Outbox senden, Ansicht holen, Ereigniskanal halten. // // Grundgedanke: Der Server ist die Quelle der Wahrheit. Lokal liegt sein // zuletzt gesehener Stand plus die noch nicht bestätigten eigenen // Operationen. Angezeigt wird beides übereinandergelegt. "use strict"; import { OfflineError, get, post } from "./api.js"; import * as db from "./db.js"; import { set, state } from "./store.js"; const MAX_ATTEMPTS = 8; // --------------------------------------------------------------------------- // Operationen lokal auf die zwischengespeicherte Ansicht anwenden // --------------------------------------------------------------------------- function allItems(view) { return view.markets.flatMap((m) => m.categories.flatMap((c) => c.items)); } /** Baut die Gruppierung neu auf, nachdem sich Zuordnungen geändert haben. * Dieselbe Reihenfolge wie serverseitig: Märkte und Warengruppen nach * sort_order, dann Name; Artikel alphabetisch. */ function regroup(view, markets, categories) { const items = allItems(view); const marketById = new Map(markets.map((m) => [m.id, m])); const categoryById = new Map(categories.map((c) => [c.id, c])); const buckets = new Map(); for (const item of items) { const mid = marketById.has(item.market_id) ? item.market_id : null; const cid = categoryById.has(item.category_id) ? item.category_id : null; if (!buckets.has(mid)) buckets.set(mid, new Map()); const inner = buckets.get(mid); if (!inner.has(cid)) inner.set(cid, []); inner.get(cid).push(item); } const rank = (id, byId) => { if (id === null) return [1, 0, ""]; const entry = byId.get(id); return [0, entry.sort_order, entry.name.toLocaleLowerCase("de")]; }; const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || a[2].localeCompare(b[2], "de"); const marketIds = [...buckets.keys()].sort( (a, b) => cmp(rank(a, marketById), rank(b, marketById)) ); let grand = 0; view.markets = marketIds.map((mid) => { const inner = buckets.get(mid); const categoryIds = [...inner.keys()].sort( (a, b) => cmp(rank(a, categoryById), rank(b, categoryById)) ); let total = 0; let open = 0; const cats = categoryIds.map((cid) => { const group = inner.get(cid).sort((a, b) => a.article_name.localeCompare(b.article_name, "de")); for (const item of group) { if (item.status === "open") open += 1; if (item.price_cents) { // Preis je Gebinde mal Stückzahl - nicht mal Packungsgröße. total += item.price_cents * (item.count || 1); } } return { category_id: cid, category_name: cid ? categoryById.get(cid).name : "Ohne Warengruppe", items: group, }; }); grand += total; return { market_id: mid, market_name: mid ? marketById.get(mid).name : "Ohne Markt", categories: cats, open_count: open, total_cents: total, }; }); view.grand_total_cents = grand; return view; } /** Wendet eine noch nicht bestätigte Operation auf die Ansicht an, damit * offline getätigte Änderungen sofort sichtbar sind. */ export function applyOp(view, op, markets, categories) { const items = allItems(view); const find = (id) => items.find((i) => i.id === id); let needsRegroup = false; if (op.kind === "item.create") { const p = op.payload; // Vorläufiger Eintrag: Die endgültige ID vergibt der Server. items.push({ id: op.op_id, pending: true, article_id: null, article_name: p.article_name, created_by: null, created_by_name: null, market_id: p.market_id ?? null, category_id: p.category_id ?? null, count: p.count ?? 1, pack_size: p.pack_size ?? null, pack_unit: p.pack_unit ?? null, variant: p.variant ?? null, note: p.note ?? null, status: "open", price_cents: null, total_cents: null, row_rev: 0, updated_at: op.created_at, }); // items ist eine Kopie - deshalb über regroup zurückschreiben. view.markets = [{ market_id: null, market_name: "", categories: [ { category_id: null, category_name: "", items }, ], open_count: 0, total_cents: 0 }]; needsRegroup = true; } else if (op.kind === "item.update") { const item = find(op.payload.item_id); if (item) { const p = op.payload; if (p.clear_market) { item.market_id = null; needsRegroup = true; } else if ("market_id" in p) { item.market_id = p.market_id; needsRegroup = true; } if (p.clear_category) { item.category_id = null; needsRegroup = true; } else if ("category_id" in p) { item.category_id = p.category_id; needsRegroup = true; } for (const field of ["count", "pack_size", "pack_unit", "variant", "note", "status", "price_cents"]) { if (field in p) item[field] = p[field]; } // Zeilensumme lokal nachziehen, damit die Anzeige sofort stimmt. item.total_cents = item.price_cents ? item.price_cents * (item.count || 1) : null; item.pending = true; needsRegroup = true; } } else if (op.kind === "item.delete") { const keep = items.filter((i) => i.id !== op.payload.item_id); view.markets = [{ market_id: null, market_name: "", categories: [ { category_id: null, category_name: "", items: keep }, ], open_count: 0, total_cents: 0 }]; needsRegroup = true; } else if (op.kind === "items.clear_bought") { const keep = items.filter((i) => i.status !== "bought"); view.markets = [{ market_id: null, market_name: "", categories: [ { category_id: null, category_name: "", items: keep }, ], open_count: 0, total_cents: 0 }]; needsRegroup = true; } return needsRegroup ? regroup(view, markets, categories) : view; } /** Serverstand plus alle offenen Operationen. */ export async function composeView(listId) { const cached = await db.cacheGet(`view:${listId}`); if (!cached) return null; const markets = (await db.cacheGet(`markets:${listId}`)) || []; const categories = (await db.cacheGet(`categories:${listId}`)) || []; // Tiefe Kopie, damit der zwischengespeicherte Serverstand unberührt bleibt. let view = structuredClone(cached); const ops = await db.pending(listId); for (const op of ops.sort((a, b) => a.seq - b.seq)) { view = applyOp(view, op, markets, categories); } view.pending_ops = ops.length; return view; } // --------------------------------------------------------------------------- // Senden und Holen // --------------------------------------------------------------------------- let flushing = false; /** Schickt die Warteschlange. Gibt zurück, ob etwas gesendet wurde. */ export async function flush(listId) { if (flushing) return false; const ops = (await db.pending(listId)).sort((a, b) => a.seq - b.seq); if (!ops.length) return false; // Operationen, die zu oft gescheitert sind, blockieren sonst dauerhaft // alles Nachfolgende - sie werden verworfen und gemeldet. const stuck = ops.filter((o) => o.attempts >= MAX_ATTEMPTS); if (stuck.length) { await db.remove(stuck.map((o) => o.op_id)); set({ syncProblem: `${stuck.length} Änderung(en) konnten nicht gespeichert werden und wurden verworfen.` }); } const sending = ops.filter((o) => o.attempts < MAX_ATTEMPTS).slice(0, 200); if (!sending.length) return false; flushing = true; try { const result = await post(`/api/lists/${listId}/ops`, { ops: sending.map((o) => ({ op_id: o.op_id, kind: o.kind, payload: o.payload })), }); const done = []; const rejected = []; for (const r of result.results) { if (r.status === "rejected") rejected.push(r); done.push(r.op_id); } await db.remove(done); if (rejected.length) { set({ syncProblem: rejected[0].error ? `Eine Änderung wurde abgelehnt: ${rejected[0].error}` : "Eine Änderung wurde abgelehnt." }); } return true; } catch (err) { if (err instanceof OfflineError) { // Kein Fehler im eigentlichen Sinn - beim nächsten Versuch erneut. return false; } await db.bumpAttempts(sending.map((o) => o.op_id)); set({ syncProblem: err.message }); return false; } finally { flushing = false; } } /** Holt den Serverstand und legt ihn in den Zwischenspeicher. * * Ein einziger Aufruf statt vier: /snapshot liefert Liste, Ansicht, * Märkte und Warengruppen zusammen. Bei einer Synchronisation nach * jedem Abhaken sparen die drei eingesparten Rundreisen spürbar Zeit, * besonders im Mobilfunknetz. */ export async function pull(listId) { const snapshot = await get(`/api/lists/${listId}/snapshot`); const { view, markets, categories } = snapshot; const meta = snapshot.list; await Promise.all([ db.cacheSet(`view:${listId}`, view), db.cacheSet(`markets:${listId}`, markets), db.cacheSet(`categories:${listId}`, categories), db.cacheSet(`meta:${listId}`, meta), ]); return { view, markets, categories, meta }; } /** Der übliche Ablauf: erst senden, dann holen. Umgekehrt würde der * frisch geholte Stand die eigenen offenen Änderungen überdecken. */ export async function syncNow(listId) { try { await flush(listId); await pull(listId); set({ lastSync: new Date().toISOString(), syncState: "ok" }); return true; } catch (err) { set({ syncState: err instanceof OfflineError ? "offline" : "error" }); return false; } } // --------------------------------------------------------------------------- // Ereigniskanal // --------------------------------------------------------------------------- let source = null; let sourceListId = null; export function watch(listId, onChange) { stopWatching(); if (!("EventSource" in globalThis)) return; sourceListId = listId; source = new EventSource(`/api/lists/${listId}/events`); source.addEventListener("rev", async (ev) => { let rev = null; try { rev = JSON.parse(ev.data).rev; } catch { return; } const cached = await db.cacheGet(`view:${listId}`); // Nur holen, wenn sich wirklich etwas geändert hat. if (!cached || cached.rev !== rev) { await syncNow(listId); onChange(); } }); source.addEventListener("gone", () => { stopWatching(); onChange(); }); // Wiederverbindung übernimmt der Browser selbst; nur der Zustand wird // gemeldet, damit die Oberfläche es anzeigen kann. source.onerror = () => set({ syncState: "offline" }); source.onopen = () => set({ syncState: "ok" }); } export function stopWatching() { if (source) { source.close(); source = null; sourceListId = null; } } export function watchedList() { return sourceListId; } // --------------------------------------------------------------------------- // Auslöser für den Versand // --------------------------------------------------------------------------- export function installTriggers(getListId, onChange) { const attempt = async () => { const listId = getListId(); if (!listId) return; // Nur senden. Das anschließende Holen übernimmt die Ansicht, die // onChange auslöst - sonst liefe pull() zweimal hintereinander. if (await flush(listId)) onChange(); }; // Verbindung zurück: sofort versuchen. window.addEventListener("online", () => { set({ online: true, syncState: "ok" }); attempt(); }); window.addEventListener("offline", () => set({ online: false, syncState: "offline" })); // App wieder im Vordergrund - typischer Moment nach dem Einkauf. document.addEventListener("visibilitychange", () => { if (document.visibilityState === "visible") attempt(); }); // Rückfallebene, falls beide Ereignisse ausbleiben. setInterval(attempt, 30000); }