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 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: def delete_shopping_item(item_id: int) -> bool:
"""Löscht einen einzelnen Eintrag aus der Einkaufsliste.""" """Löscht einen einzelnen Eintrag aus der Einkaufsliste."""
query = "DELETE FROM shopping_list WHERE id = ?" query = "DELETE FROM shopping_list WHERE id = ?"
+5 -1
View File
@@ -47,6 +47,7 @@ from .database import (
add_shopping_item, add_shopping_item,
add_shopping_items_bulk, add_shopping_items_bulk,
toggle_shopping_item, toggle_shopping_item,
set_shopping_item_checked,
delete_shopping_item, delete_shopping_item,
delete_checked_shopping_items, delete_checked_shopping_items,
clear_shopping_list, clear_shopping_list,
@@ -1075,7 +1076,10 @@ async def api_add_shopping_bulk(payload: ShoppingBulkCreate):
@app.post("/api/shopping-list/{item_id}/toggle") @app.post("/api/shopping-list/{item_id}/toggle")
async def api_toggle_shopping_item(item_id: int): 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) updated = toggle_shopping_item(item_id)
if not updated: if not updated:
raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.") raise HTTPException(status_code=404, detail="Eintrag nicht gefunden.")
+103 -27
View File
@@ -70,6 +70,32 @@
color: #FFF; 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 */ /* Hauptinhalt Container */
.shop-main-container { .shop-main-container {
max-width: 820px; max-width: 820px;
@@ -737,17 +763,41 @@
let ws = null; let ws = null;
let reconnectTimeout = null; let reconnectTimeout = null;
let pingInterval = null; let pingInterval = null;
let isSyncingQueue = false;
// Server-Daten einbinden // Server-Daten & Offline-Cache einbinden
let serverItems = [];
let serverCats = [];
try { try {
allItems = {{ items_json | safe }}; serverItems = {{ items_json | safe }};
allCategories = {{ categories_json | safe }}; serverCats = {{ categories_json | safe }};
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
localStorage.setItem('wtc_shop_cats_cache', JSON.stringify(allCategories));
} catch (e) { } catch (e) {
console.warn("Lade Daten aus Offline-Cache:", e); serverItems = [];
allItems = JSON.parse(localStorage.getItem('wtc_shop_items_cache') || '[]'); serverCats = [];
allCategories = JSON.parse(localStorage.getItem('wtc_shop_cats_cache') || '[]'); }
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 { try {
const data = JSON.parse(event.data); const data = JSON.parse(event.data);
if (data.action === 'items_updated') { if (data.action === 'items_updated') {
if (isSyncingQueue) return;
allItems = data.items; allItems = data.items;
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems)); saveLocalCache(allItems);
renderShoppingList(); renderShoppingList();
} else if (data.action === 'categories_updated') { } else if (data.action === 'categories_updated') {
if (isSyncingQueue) return;
allCategories = data.categories; 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_cats_cache', JSON.stringify(allCategories));
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
renderCategoryFilterChips(); renderCategoryFilterChips();
renderShoppingList(); renderShoppingList();
} }
@@ -873,10 +927,12 @@
} }
async function flushOfflineQueue() { async function flushOfflineQueue() {
if (isSyncingQueue) return;
const q = getOfflineQueue(); const q = getOfflineQueue();
if (!q.length) return; if (!q.length) return;
localStorage.removeItem('wtc_shop_offline_queue');
isSyncingQueue = true;
try {
for (const act of q) { for (const act of q) {
try { try {
await fetch(act.url, { await fetch(act.url, {
@@ -888,7 +944,12 @@
console.warn("Fehler beim Nachholen einer Offline-Aktion:", e); console.warn("Fehler beim Nachholen einer Offline-Aktion:", e);
} }
} }
refreshDataFromServer(); localStorage.removeItem('wtc_shop_offline_queue');
await refreshDataFromServer();
showToast('✓ Offline-Änderungen synchronisiert');
} finally {
isSyncingQueue = false;
}
} }
async function refreshDataFromServer() { async function refreshDataFromServer() {
@@ -1085,14 +1146,14 @@
// Optimistic UI Update // Optimistic UI Update
item.checked = !item.checked; item.checked = !item.checked;
saveLocalCache(allItems);
renderShoppingList(); renderShoppingList();
try { 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'); if (!res.ok) throw new Error('Fehler beim Abhaken');
} catch (err) { } catch (err) {
pushOfflineQueue({ method: 'POST', url: `/api/shopping-list/${itemId}/toggle` }); pushOfflineQueue({ method: 'POST', url: `/api/shopping-list/${itemId}/toggle?checked=${item.checked}` });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
} }
} }
@@ -1101,6 +1162,7 @@
// Optimistic remove // Optimistic remove
allItems = allItems.filter(it => it.id !== itemId); allItems = allItems.filter(it => it.id !== itemId);
saveLocalCache(allItems);
renderShoppingList(); renderShoppingList();
showToast('Eintrag gelöscht'); showToast('Eintrag gelöscht');
@@ -1109,7 +1171,6 @@
if (!res.ok) throw new Error('Fehler beim Löschen'); if (!res.ok) throw new Error('Fehler beim Löschen');
} catch (err) { } catch (err) {
pushOfflineQueue({ method: 'DELETE', url: `/api/shopping-list/${itemId}` }); 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); allItems = allItems.filter(it => !it.checked);
saveLocalCache(allItems);
renderShoppingList(); renderShoppingList();
showToast(`${checkedCount} erledigte Artikel gelöscht`); showToast(`${checkedCount} erledigte Artikel gelöscht`);
@@ -1128,7 +1190,6 @@
await fetch('/api/shopping-list/checked', { method: 'DELETE' }); await fetch('/api/shopping-list/checked', { method: 'DELETE' });
} catch (err) { } catch (err) {
pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/checked' }); 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(); const data = await res.json();
if (data.item) { if (data.item) {
allItems.push(data.item); allItems.push(data.item);
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems)); saveLocalCache(allItems);
renderShoppingList(); renderShoppingList();
renderCategoryFilterChips(); renderCategoryFilterChips();
} }
@@ -1197,8 +1258,9 @@
}; };
allItems.push(tempItem); allItems.push(tempItem);
pushOfflineQueue({ method: 'POST', url: '/api/shopping-list', body: payload }); pushOfflineQueue({ method: 'POST', url: '/api/shopping-list', body: payload });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems)); saveLocalCache(allItems);
renderShoppingList(); renderShoppingList();
renderCategoryFilterChips();
} }
showToast('Eintrag hinzugefügt'); showToast('Eintrag hinzugefügt');
} }
@@ -1242,6 +1304,7 @@
item.amount = amount; item.amount = amount;
item.unit = unit; item.unit = unit;
item.note = note; item.note = note;
saveLocalCache(allItems);
renderShoppingList(); renderShoppingList();
renderCategoryFilterChips(); renderCategoryFilterChips();
} }
@@ -1262,7 +1325,6 @@
}); });
} catch (err) { } catch (err) {
pushOfflineQueue({ method: 'PUT', url: `/api/shopping-list/${id}`, body: payload }); pushOfflineQueue({ method: 'PUT', url: `/api/shopping-list/${id}`, body: payload });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems));
} }
showToast('Eintrag aktualisiert'); showToast('Eintrag aktualisiert');
} }
@@ -1289,6 +1351,7 @@
async function confirmClearAllItems() { async function confirmClearAllItems() {
closeModal('modal-delete-step2'); closeModal('modal-delete-step2');
allItems = []; allItems = [];
saveLocalCache([]);
renderShoppingList(); renderShoppingList();
renderCategoryFilterChips(); renderCategoryFilterChips();
showToast('Einkaufsliste komplett geleert'); showToast('Einkaufsliste komplett geleert');
@@ -1297,7 +1360,6 @@
await fetch('/api/shopping-list/all', { method: 'DELETE' }); await fetch('/api/shopping-list/all', { method: 'DELETE' });
} catch (err) { } catch (err) {
pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/all' }); pushOfflineQueue({ method: 'DELETE', url: '/api/shopping-list/all' });
localStorage.setItem('wtc_shop_items_cache', JSON.stringify([]));
} }
} }
@@ -1317,8 +1379,9 @@
} }
body.innerHTML = ` body.innerHTML = `
<div style="text-align: center; padding: 25px; color: #666;"> <div style="text-align: center; padding: 35px 20px; color: #555;">
<span>⏳ Zutaten des Wochenplans werden geladen...</span> <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> </div>
`; `;
@@ -1448,6 +1511,12 @@
const cbs = Array.from(document.querySelectorAll('.mealplan-ing-checkbox:checked')); const cbs = Array.from(document.querySelectorAll('.mealplan-ing-checkbox:checked'));
if (cbs.length === 0) return; 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 => ({ const itemsToAdd = cbs.map(cb => ({
item_name: cb.dataset.name, item_name: cb.dataset.name,
amount: cb.dataset.amount || '', amount: cb.dataset.amount || '',
@@ -1457,8 +1526,6 @@
note: '' note: ''
})); }));
closeModal('modal-import-mealplan');
try { try {
const res = await fetch('/api/shopping-list/bulk', { const res = await fetch('/api/shopping-list/bulk', {
method: 'POST', method: 'POST',
@@ -1472,16 +1539,25 @@
} else { } else {
await refreshDataFromServer(); await refreshDataFromServer();
} }
localStorage.setItem('wtc_shop_items_cache', JSON.stringify(allItems)); saveLocalCache(allItems);
closeModal('modal-import-mealplan');
renderCategoryFilterChips(); renderCategoryFilterChips();
renderShoppingList(); renderShoppingList();
showToast(`✓ ${itemsToAdd.length} Zutaten übernommen`); showToast(`✓ ${itemsToAdd.length} Zutaten übernommen`);
} else { } else {
showToast('Fehler beim Übernehmen'); showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
} }
} catch (err) { } catch (err) {
console.error("Fehler beim Importieren der Zutaten:", err); console.error("Fehler beim Importieren der Zutaten:", err);
showToast('Fehler beim Übernehmen'); showToast('Fehler beim Übernehmen');
if (confirmBtn) {
confirmBtn.disabled = false;
confirmBtn.textContent = 'Ausgewählte übernehmen';
}
} }
} }