Erste Produktivversion
This commit is contained in:
349
web/html/js/sync.js
Normal file
349
web/html/js/sync.js
Normal file
@@ -0,0 +1,349 @@
|
||||
// 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);
|
||||
}
|
||||
Reference in New Issue
Block a user