Files
einkaufsapp/web/html/js/sync.js
2026-08-08 20:31:58 +02:00

350 lines
12 KiB
JavaScript

// 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);
}