Fix offline sync priority, idempotent check state, and add loading spinner for meal plan import

This commit is contained in:
2026-10-15 22:29:12 +02:00
parent 9665e245c2
commit 0196c6d9e2
3 changed files with 130 additions and 38 deletions
+12
View File
@@ -1182,6 +1182,18 @@ def toggle_shopping_item(item_id: int) -> Optional[Dict[str, Any]]:
return row
def set_shopping_item_checked(item_id: int, checked: bool) -> Optional[Dict[str, Any]]:
"""Setzt den Erledigt-Status eines Eintrags explizit auf True oder False."""
val = 1 if checked else 0
query = "UPDATE shopping_list SET checked = ? WHERE id = ?"
execute_query(query, (val, item_id), fetch="none", commit=True)
row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one")
if row:
row["checked"] = bool(row.get("checked"))
row["display_source"] = clean_source_label(row.get("source", ""))
return row
def delete_shopping_item(item_id: int) -> bool:
"""Löscht einen einzelnen Eintrag aus der Einkaufsliste."""
query = "DELETE FROM shopping_list WHERE id = ?"
+6 -2
View File
@@ -47,6 +47,7 @@ from .database import (
add_shopping_item,
add_shopping_items_bulk,
toggle_shopping_item,
set_shopping_item_checked,
delete_shopping_item,
delete_checked_shopping_items,
clear_shopping_list,
@@ -1075,8 +1076,11 @@ async def api_add_shopping_bulk(payload: ShoppingBulkCreate):
@app.post("/api/shopping-list/{item_id}/toggle")
async def api_toggle_shopping_item(item_id: int):
updated = toggle_shopping_item(item_id)
async def api_toggle_shopping_item(item_id: int, checked: Optional[bool] = Query(None)):
if checked is not None:
updated = set_shopping_item_checked(item_id, checked)
else:
updated = toggle_shopping_item(item_id)
if not updated:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
+112 -36
View File
@@ -70,6 +70,32 @@
color: #FFF;
}
/* Lade-Spinner Animationen */
@keyframes shopSpin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.shop-spinner {
display: inline-block;
width: 15px;
height: 15px;
border: 2px solid rgba(255, 255, 255, 0.35);
border-radius: 50%;
border-top-color: #ffffff;
animation: shopSpin 0.7s linear infinite;
vertical-align: middle;
}
.shop-spinner-green {
display: inline-block;
width: 28px;
height: 28px;
border: 3px solid rgba(30, 77, 59, 0.2);
border-radius: 50%;
border-top-color: var(--shop-green);
animation: shopSpin 0.7s linear infinite;
vertical-align: middle;
}
/* Hauptinhalt Container */
.shop-main-container {
max-width: 820px;
@@ -737,17 +763,41 @@
let ws = null;
let reconnectTimeout = null;
let pingInterval = null;
let isSyncingQueue = false;
// Server-Daten einbinden
// Server-Daten & Offline-Cache einbinden
let serverItems = [];
let serverCats = [];
try {
allItems = {{ items_json | safe }};
allCategories = {{ categories_json | safe }};
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
serverItems = {{ items_json | safe }};
serverCats = {{ categories_json | safe }};
} catch (e) {
console.warn("Lade Daten aus Offline-Cache:", e);
allItems = JSON.parse(localStorage.getItem('wtc_shop_items_cache') || '[]');
allCategories = JSON.parse(localStorage.getItem('wtc_shop_cats_cache') || '[]');
serverItems = [];
serverCats = [];
}
const cachedQueue = JSON.parse(localStorage.getItem('wtc_shop_offline_queue') || '[]');
const cachedItems = JSON.parse(localStorage.getItem('wtc_shop_items_cache') || 'null');
const cachedCats = JSON.parse(localStorage.getItem('wtc_shop_cats_cache') || 'null');
// Wenn offline ODER noch ungesendete Offline-Änderungen vorliegen: Lokalen Cache priorisieren!
if (!navigator.onLine || cachedQueue.length > 0) {
allItems = (cachedItems !== null) ? cachedItems : (serverItems || []);
allCategories = (cachedCats !== null) ? cachedCats : (serverCats || []);
} else {
allItems = (serverItems && serverItems.length) ? serverItems : (cachedItems || []);
allCategories = (serverCats && serverCats.length) ? serverCats : (cachedCats || []);
saveLocalCache(allItems);
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
}
function saveLocalCache(items) {
allItems = items;
try {
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(items));
} catch (e) {
console.warn('LocalStorage voll oder nicht verfügbar:', e);
}
}
// ==========================================
@@ -801,14 +851,18 @@
try {
const data = JSON.parse(event.data);
if (data.action === 'items_updated') {
if (isSyncingQueue) return;
allItems = data.items;
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
saveLocalCache(allItems);
renderShoppingList();
} else if (data.action === 'categories_updated') {
if (isSyncingQueue) return;
allCategories = data.categories;
if (data.items) allItems = data.items;
if (data.items) {
allItems = data.items;
saveLocalCache(allItems);
}
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
renderCategoryFilterChips();
renderShoppingList();
}
@@ -873,22 +927,29 @@
}
async function flushOfflineQueue() {
if (isSyncingQueue) return;
const q = getOfflineQueue();
if (!q.length) return;
localStorage.removeItem('wtc_shop_offline_queue');
for (const act of q) {
try {
await fetch(act.url, {
method: act.method,
headers: { 'Content-Type': 'application/json' },
body: act.body ? JSON.stringify(act.body) : undefined
});
} catch (e) {
console.warn("Fehler beim Nachholen einer Offline-Aktion:", e);
isSyncingQueue = true;
try {
for (const act of q) {
try {
await fetch(act.url, {
method: act.method,
headers: { 'Content-Type': 'application/json' },
body: act.body ? JSON.stringify(act.body) : undefined
});
} catch (e) {
console.warn("Fehler beim Nachholen einer Offline-Aktion:", e);
}
}
localStorage.removeItem('wtc_shop_offline_queue');
await refreshDataFromServer();
showToast('✓ Offline-Änderungen synchronisiert');
} finally {
isSyncingQueue = false;
}
refreshDataFromServer();
}
async function refreshDataFromServer() {
@@ -1085,14 +1146,14 @@
// Optimistic UI Update
item.checked = !item.checked;
saveLocalCache(allItems);
renderShoppingList();
try {
const res = await fetch(`/api/shopping-list/${itemId}/toggle`, { method: 'POST' });
const res = await fetch(`/api/shopping-list/${itemId}/toggle?checked=${item.checked}`, { method: 'POST' });
if (!res.ok) throw new Error('Fehler beim Abhaken');
} catch (err) {
pushOfflineQueue({ method: 'POST', url: `/api/shopping-list/${itemId}/toggle` });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
pushOfflineQueue({ method: 'POST', url: `/api/shopping-list/${itemId}/toggle?checked=${item.checked}` });
}
}
@@ -1101,6 +1162,7 @@
// Optimistic remove
allItems = allItems.filter(it => it.id !== itemId);
saveLocalCache(allItems);
renderShoppingList();
showToast('Eintrag gelöscht');
@@ -1109,7 +1171,6 @@
if (!res.ok) throw new Error('Fehler beim Löschen');
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: `/api/shopping-list/${itemId}` });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
}
}
@@ -1121,6 +1182,7 @@
}
allItems = allItems.filter(it => !it.checked);
saveLocalCache(allItems);
renderShoppingList();
showToast(`${checkedCount} erledigte Artikel gelöscht`);
@@ -1128,7 +1190,6 @@
await fetch('/api/shopping-list/checked', { method: 'DELETE' });
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/checked' });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
}
}
@@ -1176,7 +1237,7 @@
const data = await res.json();
if (data.item) {
allItems.push(data.item);
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
}
@@ -1197,8 +1258,9 @@
};
allItems.push(tempItem);
pushOfflineQueue({ method: 'POST', url: '/api/shopping-list', body: payload });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
}
showToast('Eintrag hinzugefügt');
}
@@ -1242,6 +1304,7 @@
item.amount = amount;
item.unit = unit;
item.note = note;
saveLocalCache(allItems);
renderShoppingList();
renderCategoryFilterChips();
}
@@ -1262,7 +1325,6 @@
});
} catch (err) {
pushOfflineQueue({ method: 'PUT', url: `/api/shopping-list/${id}`, body: payload });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
}
showToast('Eintrag aktualisiert');
}
@@ -1289,6 +1351,7 @@
async function confirmClearAllItems() {
closeModal('modal-delete-step2');
allItems = [];
saveLocalCache([]);
renderShoppingList();
renderCategoryFilterChips();
showToast('Einkaufsliste komplett geleert');
@@ -1297,7 +1360,6 @@
await fetch('/api/shopping-list/all', { method: 'DELETE' });
} catch (err) {
pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/all' });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify([]));
}
}
@@ -1317,8 +1379,9 @@
}
body.innerHTML = `
<div style="text-align: center; padding: 25px; color: #666;">
<span>⏳ Zutaten des Wochenplans werden geladen...</span>
<div style="text-align: center; padding: 35px 20px; color: #555;">
<div class="shop-spinner shop-spinner-green" style="margin-bottom: 12px;"></div>
<div style="font-weight: 600; font-size: 0.95rem; color: var(--shop-green);">Zutaten des Wochenplans werden geladen...</div>
</div>
`;
@@ -1448,6 +1511,12 @@
const cbs = Array.from(document.querySelectorAll('.mealplan-ing-checkbox:checked'));
if (cbs.length === 0) return;
const confirmBtn = document.getElementById('btn-confirm-import-mealplan');
if (confirmBtn) {
confirmBtn.disabled = true;
confirmBtn.innerHTML = '<span class="shop-spinner" style="margin-right: 6px;"></span> <span>Wird übernommen...</span>';
}
const itemsToAdd = cbs.map(cb => ({
item_name: cb.dataset.name,
amount: cb.dataset.amount || '',
@@ -1457,8 +1526,6 @@
note: ''
}));
closeModal('modal-import-mealplan');
try {
const res = await fetch('/api/shopping-list/bulk', {
method: 'POST',
@@ -1472,16 +1539,25 @@
} else {
await refreshDataFromServer();
}
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
saveLocalCache(allItems);
closeModal('modal-import-mealplan');
renderCategoryFilterChips();
renderShoppingList();
showToast(`✓ ${itemsToAdd.length} Zutaten übernommen`);
} else {
showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
}
} catch (err) {
console.error("Fehler beim Importieren der Zutaten:", err);
showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
}
}