Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

151
web/html/js/db.js Normal file
View File

@@ -0,0 +1,151 @@
// Lokaler Speicher auf Basis von IndexedDB.
//
// Warum nicht localStorage: Der ist synchron (blockiert die Oberfläche),
// auf wenige Megabyte begrenzt und kennt keine Transaktionen. Für eine
// Warteschlange, die auch einen Absturz überleben muss, ist er ungeeignet.
"use strict";
const DB_NAME = "einkaufsapp";
const DB_VERSION = 1;
const STORE_CACHE = "cache"; // Schlüssel -> zuletzt gesehener Serverstand
const STORE_OUTBOX = "outbox"; // noch nicht bestätigte Operationen
let dbPromise = null;
function open() {
if (dbPromise) return dbPromise;
dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = () => {
const db = request.result;
if (!db.objectStoreNames.contains(STORE_CACHE)) {
db.createObjectStore(STORE_CACHE);
}
if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
const store = db.createObjectStore(STORE_OUTBOX, { keyPath: "op_id" });
// Reihenfolge je Liste: Operationen müssen in der Folge ankommen,
// in der sie entstanden sind.
store.createIndex("by_list_seq", ["list_id", "seq"]);
}
};
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
request.onblocked = () =>
reject(new Error("Datenbank blockiert bitte andere Tabs schließen."));
});
return dbPromise;
}
function run(storeName, mode, fn) {
return open().then((db) => new Promise((resolve, reject) => {
const tx = db.transaction(storeName, mode);
const store = tx.objectStore(storeName);
let result;
try {
result = fn(store);
} catch (err) {
reject(err);
return;
}
tx.oncomplete = () => resolve(result && result.__req ? result.__req.result : result);
tx.onerror = () => reject(tx.error);
tx.onabort = () => reject(tx.error || new Error("Transaktion abgebrochen"));
}));
}
function wrap(request) {
return { __req: request };
}
// ---------------------------------------------------------------------------
// Zwischenspeicher
// ---------------------------------------------------------------------------
export function cacheGet(key) {
return run(STORE_CACHE, "readonly", (store) => wrap(store.get(key)));
}
export function cacheSet(key, value) {
return run(STORE_CACHE, "readwrite", (store) => wrap(store.put(value, key)));
}
export function cacheDelete(key) {
return run(STORE_CACHE, "readwrite", (store) => wrap(store.delete(key)));
}
// ---------------------------------------------------------------------------
// Outbox
// ---------------------------------------------------------------------------
/** Zufällige, praktisch eindeutige Kennung. crypto.randomUUID gibt es
* nur in sicheren Kontexten (HTTPS oder localhost) - deshalb der
* Rückfallweg. */
export function newOpId() {
if (globalThis.crypto?.randomUUID) return crypto.randomUUID();
const bytes = new Uint8Array(16);
(globalThis.crypto || {}).getRandomValues?.(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|| `${Date.now()}-${Math.random().toString(16).slice(2)}`;
}
let seqCounter = Date.now();
export function enqueue(listId, kind, payload) {
const op = {
op_id: newOpId(),
list_id: listId,
kind,
payload,
seq: seqCounter++,
created_at: new Date().toISOString(),
attempts: 0,
};
return run(STORE_OUTBOX, "readwrite", (store) => {
store.add(op);
return op;
});
}
export function pending(listId) {
return run(STORE_OUTBOX, "readonly", (store) => {
const index = store.index("by_list_seq");
const range = IDBKeyRange.bound([listId, -Infinity], [listId, Infinity]);
return wrap(index.getAll(range));
});
}
export function pendingAll() {
return run(STORE_OUTBOX, "readonly", (store) => wrap(store.getAll()));
}
export function remove(opIds) {
return run(STORE_OUTBOX, "readwrite", (store) => {
for (const id of opIds) store.delete(id);
});
}
export function bumpAttempts(opIds) {
return run(STORE_OUTBOX, "readwrite", (store) => {
for (const id of opIds) {
const request = store.get(id);
request.onsuccess = () => {
const op = request.result;
if (op) {
op.attempts = (op.attempts || 0) + 1;
op.last_error_at = new Date().toISOString();
store.put(op);
}
};
}
});
}
export async function clearList(listId) {
const ops = await pending(listId);
await remove(ops.map((o) => o.op_id));
}