354 lines
12 KiB
JavaScript
354 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; }
|
|
// Der Server entscheidet, ob umbenannt oder umgehängt wird - für
|
|
// die Anzeige ist beides derselbe neue Name.
|
|
if (p.article_name) item.article_name = p.article_name;
|
|
|
|
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);
|
|
}
|