Fix offline sync priority, idempotent check state, and add loading spinner for meal plan import
This commit is contained in:
@@ -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 = ?"
|
||||||
|
|||||||
+6
-2
@@ -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,8 +1076,11 @@ 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)):
|
||||||
updated = toggle_shopping_item(item_id)
|
if checked is not None:
|
||||||
|
updated = set_shopping_item_checked(item_id, checked)
|
||||||
|
else:
|
||||||
|
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.")
|
||||||
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
|
await shopping_ws_manager.broadcast({"action": "items_updated", "items": get_shopping_list()})
|
||||||
|
|||||||
@@ -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,22 +927,29 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
for (const act of q) {
|
isSyncingQueue = true;
|
||||||
try {
|
try {
|
||||||
await fetch(act.url, {
|
for (const act of q) {
|
||||||
method: act.method,
|
try {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
await fetch(act.url, {
|
||||||
body: act.body ? JSON.stringify(act.body) : undefined
|
method: act.method,
|
||||||
});
|
headers: { 'Content-Type': 'application/json' },
|
||||||
} catch (e) {
|
body: act.body ? JSON.stringify(act.body) : undefined
|
||||||
console.warn("Fehler beim Nachholen einer Offline-Aktion:", e);
|
});
|
||||||
|
} 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() {
|
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';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user