From d1716fa6ff9a5548c2d6f383c2a06207267e6aff Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Sun, 14 Jun 2026 12:43:03 +0000 Subject: [PATCH] Fix shopping estimates, waste reasons, and recurring DB/timeouts. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Price each list line as one retail purchase; learn from discard reasons to cap restock suggestions. Retry inventory_use/shopping_add on SQLITE_BUSY; extend smart_shopping time limit. Reopen feature issues #98/#125; close auto-report bugs #201–#204. Co-authored-by: Cursor --- api/index.php | 172 ++++++++++++++++++++++++++++++-- assets/js/app.js | 79 ++++++++++----- scripts/github-issue-triage.php | 81 +++++++++++++++ translations/de.json | 12 +++ translations/en.json | 12 +++ translations/es.json | 12 +++ translations/fr.json | 12 +++ translations/it.json | 12 +++ 8 files changed, 362 insertions(+), 30 deletions(-) create mode 100644 scripts/github-issue-triage.php diff --git a/api/index.php b/api/index.php index 3a0ec63..e174a5e 100644 --- a/api/index.php +++ b/api/index.php @@ -3127,6 +3127,121 @@ function addToInventory(PDO $db): void { invalidateSmartShoppingCache(); } +/** Waste transaction notes use format Buttato|reason_key (legacy: plain "Buttato"). */ +function _isWasteNotes(string $notes): bool { + return $notes === 'Buttato' || str_starts_with($notes, 'Buttato|'); +} + +function _wasteReasonKey(string $notes): ?string { + if ($notes === 'Buttato') { + return 'unknown'; + } + if (preg_match('/^Buttato\|([a-z_]+)/', $notes, $m)) { + return $m[1]; + } + return null; +} + +function _loadWasteLearning(PDO $db): array { + static $cache = null; + if ($cache !== null) { + return $cache; + } + $row = $db->query("SELECT value FROM app_settings WHERE key = 'waste_learning'")->fetchColumn(); + $cache = ($row !== false && $row !== '') ? (json_decode((string)$row, true) ?: []) : []; + return $cache; +} + +function _saveWasteLearning(PDO $db, array $data): void { + $stmt = $db->prepare("INSERT INTO app_settings (key, value, updated_at) VALUES ('waste_learning', ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at"); + $stmt->execute([json_encode($data, JSON_UNESCAPED_UNICODE)]); + invalidateSmartShoppingCache(); +} + +function _guessPreferredStorageLocation(string $name, string $category): string { + $n = mb_strtolower($name . ' ' . $category); + if (preg_match('/surgelat|gelato|congelat|frozen|piselli surg|spinaci surg|basilico surg/', $n)) { + return 'freezer'; + } + if (preg_match('/latte|yogurt|formaggio|burro|panna|uova|insalata|rucola|spinaci|pollo|carne|pesce|prosciutto|salame|mortadella|bresaola|affettato/', $n)) { + return 'frigo'; + } + return 'dispensa'; +} + +function _applyWasteLearning(PDO $db, int $productId, string $reason, string $location, array $product): void { + if ($reason === '' || $reason === 'other') { + return; + } + $data = _loadWasteLearning($db); + $pid = (string)$productId; + if (!isset($data[$pid])) { + $data[$pid] = []; + } + $data[$pid]['last_reason'] = $reason; + $data[$pid]['last_at'] = time(); + $data[$pid]['count_' . $reason] = (int)($data[$pid]['count_' . $reason] ?? 0) + 1; + + switch ($reason) { + case 'expired': + case 'spoiled': + $data[$pid]['alert_days_sooner'] = min(5, (int)($data[$pid]['alert_days_sooner'] ?? 0) + 1); + break; + case 'wrong_location': + $preferred = _guessPreferredStorageLocation($product['name'] ?? '', $product['category'] ?? ''); + if ($preferred !== $location) { + $data[$pid]['preferred_location'] = $preferred; + } + break; + case 'kept_too_long': + case 'forgotten': + $data[$pid]['buy_smaller'] = true; + $data[$pid]['max_suggested_pz'] = 2; + break; + case 'bought_too_much': + $data[$pid]['buy_less'] = true; + $data[$pid]['max_suggested_conf'] = 1; + $data[$pid]['max_suggested_pz'] = 2; + break; + case 'bad_quality': + $data[$pid]['buy_less'] = true; + break; + } + _saveWasteLearning($db, $data); +} + +function _maybeApplyWasteLearning(PDO $db, int $productId, string $notes, string $location): void { + if (!_isWasteNotes($notes)) { + return; + } + $reason = _wasteReasonKey($notes) ?? 'unknown'; + $stmt = $db->prepare("SELECT name, category FROM products WHERE id = ?"); + $stmt->execute([$productId]); + $product = $stmt->fetch(PDO::FETCH_ASSOC); + if (!$product) { + return; + } + _applyWasteLearning($db, $productId, $reason, $location, $product); +} + +function _applyWasteHintsToSuggestion(int $productId, $suggestedQty, string $suggestedUnit, array $wasteLearning): array { + $hint = $wasteLearning[(string)$productId] ?? []; + if ($suggestedQty === null || empty($hint)) { + return [$suggestedQty, $suggestedUnit]; + } + if (!empty($hint['buy_less']) || !empty($hint['buy_smaller'])) { + if ($suggestedUnit === 'conf') { + $cap = (float)($hint['max_suggested_conf'] ?? 1); + $suggestedQty = min((float)$suggestedQty, max(1.0, $cap)); + } elseif ($suggestedUnit === 'pz') { + $cap = (float)($hint['max_suggested_pz'] ?? 2); + $suggestedQty = min((float)$suggestedQty, max(1.0, $cap)); + } + } + return [$suggestedQty, $suggestedUnit]; +} + function useFromInventory(PDO $db): void { EverLog::info('useFromInventory'); $input = json_decode(file_get_contents('php://input'), true); @@ -3143,6 +3258,18 @@ function useFromInventory(PDO $db): void { return; } + try { + dbWithRetry(function () use ($db, $productId, $quantity, $useAll, $location, $notes): void { + useFromInventoryCore($db, $productId, $quantity, $useAll, $location, $notes); + }); + } catch (\PDOException $e) { + EverLog::error('useFromInventory db error', ['msg' => $e->getMessage()]); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Database busy — please retry']); + } +} + +function useFromInventoryCore(PDO $db, $productId, $quantity, $useAll, $location, $notes): void { // ── Server-side deduplication ───────────────────────────────────────── // Guard against accidental double-consume triggers (scale jitter, double tap, // delayed/offline replay burst). We only apply this stricter gate to manual @@ -3201,10 +3328,10 @@ function useFromInventory(PDO $db): void { $stmt->execute([$productId]); $allItems = $stmt->fetchAll(); $totalRemoved = 0; - $explicitFinish = ($notes !== 'Buttato'); + $explicitFinish = !_isWasteNotes($notes); foreach ($allItems as $item) { $totalRemoved += $item['quantity']; - $type = ($notes === 'Buttato') ? 'waste' : 'out'; + $type = _isWasteNotes($notes) ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $type, $item['quantity'], $item['location'], $notes]); @@ -3218,6 +3345,7 @@ function useFromInventory(PDO $db): void { $stmt->execute([$item['id']]); } } + _maybeApplyWasteLearning($db, (int)$productId, $notes, $location === '__all__' ? 'dispensa' : $location); echo json_encode(['success' => true, 'remaining' => 0, 'removed' => $totalRemoved]); return; } @@ -3276,9 +3404,10 @@ function useFromInventory(PDO $db): void { } // Log transaction - $type = ($notes === 'Buttato') ? 'waste' : 'out'; + $type = _isWasteNotes($notes) ? 'waste' : 'out'; $stmt3 = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt3->execute([$productId, $type, $quantity, $location, $notes]); + _maybeApplyWasteLearning($db, (int)$productId, $notes, $location); $remaining = $newFraction > 0.001 ? $newFraction : 0; // Skip the normal flow — jump to Bring! check and response @@ -3367,13 +3496,14 @@ function useFromInventory(PDO $db): void { } // Log transaction (actual amount removed, not requested) - $type = ($notes === 'Buttato') ? 'waste' : 'out'; + $type = _isWasteNotes($notes) ? 'waste' : 'out'; $stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)"); $stmt->execute([$productId, $type, $actualDeducted, $location, $notes]); + _maybeApplyWasteLearning($db, (int)$productId, $notes, $location); // User explicitly chose "use all/finished": remove this row now instead of // leaving quantity=0 pending confirmation. - if ($useAll && $notes !== 'Buttato' && $newQty <= 0) { + if ($useAll && !_isWasteNotes($notes) && $newQty <= 0) { $stmt = $db->prepare("DELETE FROM inventory WHERE id = ?"); $stmt->execute([$existing['id']]); } @@ -10612,6 +10742,7 @@ function invalidateSmartShoppingCache(): void { function smartShoppingCached(PDO $db): void { EverLog::info('smartShoppingCached'); + set_time_limit(120); // Never let the browser or proxy cache this — urgency is time-sensitive header('Cache-Control: no-cache, no-store, must-revalidate'); header('Pragma: no-cache'); @@ -10690,6 +10821,7 @@ function _productOnBring(string $productName, array $bringItems, string $shoppin function smartShopping(PDO $db): void { EverLog::info('smartShopping'); + set_time_limit(120); $now = time(); $today = date('Y-m-d'); @@ -10801,6 +10933,7 @@ function smartShopping(PDO $db): void { // 5. Analyze each product $items = []; + $wasteLearning = _loadWasteLearning($db); foreach ($products as $p) { $pid = $p['id']; $inv = $inventory[$pid] ?? null; @@ -11363,6 +11496,13 @@ function smartShopping(PDO $db): void { } } + [$suggestedQty, $suggestedUnit] = _applyWasteHintsToSuggestion($pid, $suggestedQty, $suggestedUnit ?? $unit, $wasteLearning); + $wHint = $wasteLearning[(string)$pid] ?? []; + if (!empty($wHint['preferred_location'])) { + $locLabel = $wHint['preferred_location']; + $reasons[] = "Past waste: store in {$locLabel}"; + } + $items[] = [ 'product_id' => $pid, 'name' => $p['name'], @@ -11644,10 +11784,30 @@ function shoppingGetList(PDO $db): void { function shoppingAdd(PDO $db): void { if (isShoppingBringMode()) { - bringAddItems($db); + try { + dbWithRetry(function () use ($db): void { + bringAddItems($db); + }); + } catch (\PDOException $e) { + EverLog::error('shoppingAdd/bring db error', ['msg' => $e->getMessage()]); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Database busy — please retry']); + } return; } $input = json_decode(file_get_contents('php://input'), true) ?? []; + try { + dbWithRetry(function () use ($db, $input): void { + shoppingAddInternal($db, $input); + }); + } catch (\PDOException $e) { + EverLog::error('shoppingAdd db error', ['msg' => $e->getMessage()]); + http_response_code(500); + echo json_encode(['success' => false, 'error' => 'Database busy — please retry']); + } +} + +function shoppingAddInternal(PDO $db, array $input): void { $items = $input['items'] ?? []; $added = 0; $updated = 0; $skipped = 0; foreach ($items as $item) { diff --git a/assets/js/app.js b/assets/js/app.js index 3d1bb39..b8e2d55 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -6809,6 +6809,53 @@ async function quickUse(productId, location) { } } +const WASTE_REASON_KEYS = ['expired', 'spoiled', 'wrong_location', 'kept_too_long', 'bought_too_much', 'forgotten', 'bad_quality', 'other']; + +function _wasteNotesForReason(reason) { + return 'Buttato|' + reason; +} + +function _showWasteReasonModal(productLabel, onPick) { + const buttons = WASTE_REASON_KEYS.map(r => + `` + ).join(''); + document.getElementById('modal-content').innerHTML = ` + +

${escapeHtml(productLabel)}

+

${t('waste.reason_subtitle')}

+
${buttons} + +
+ `; + document.getElementById('modal-overlay').style.display = 'flex'; + document.querySelectorAll('[data-waste-reason]').forEach(btn => { + btn.addEventListener('click', () => { + const reason = btn.getAttribute('data-waste-reason'); + closeModal(); + onPick(reason); + }); + }); +} + +function _inventoryWaste(payload, productLabel) { + return new Promise((resolve, reject) => { + _showWasteReasonModal(productLabel || '', async (reason) => { + showLoading(true); + try { + const result = await api('inventory_use', {}, 'POST', { ...payload, notes: _wasteNotesForReason(reason) }); + resolve(result); + } catch (e) { + reject(e); + } finally { + showLoading(false); + } + }); + }); +} + async function deleteInventoryItem(id) { const item = currentInventory.find(i => i.id === id); const unit = item ? (item.unit || 'pz') : 'pz'; @@ -6853,19 +6900,15 @@ async function _discardOnePiece(inventoryId) { const item = currentInventory.find(i => i.id === inventoryId); if (!item) { closeModal(); return; } closeModal(); - showLoading(true); try { - await api('inventory_use', {}, 'POST', { + await _inventoryWaste({ product_id: item.product_id, quantity: 1, location: item.location, - notes: 'Buttato' - }); - showLoading(false); + }, item.name); showToast(t('toast.thrown_away_partial', { qty: 1, unit: item.unit || 'pz', name: item.name }), 'success'); refreshCurrentPage(); } catch(e) { - showLoading(false); showToast(t('error.connection'), 'error'); } } @@ -6874,19 +6917,15 @@ async function _discardAllFromModal(inventoryId) { const item = currentInventory.find(i => i.id === inventoryId); if (!item) { closeModal(); return; } closeModal(); - showLoading(true); try { - await api('inventory_use', {}, 'POST', { + await _inventoryWaste({ product_id: item.product_id, use_all: true, location: item.location, - notes: 'Buttato' - }); - showLoading(false); + }, item.name); showToast(t('toast.thrown_away', { name: item.name }), 'success'); refreshCurrentPage(); } catch(e) { - showLoading(false); showToast(t('error.connection'), 'error'); } } @@ -9180,15 +9219,12 @@ async function throwAll() { t('use.throw_all_confirm_title') || '🗑️ Butta tutto', (t('use.throw_all_confirm_msg') || 'Vuoi davvero buttare via tutto il prodotto?') + (name ? `\n"${name}"` : ''), async () => { - showLoading(true); try { - const result = await api('inventory_use', {}, 'POST', { + const result = await _inventoryWaste({ product_id: currentProduct.id, use_all: true, location: '__all__', - notes: 'Buttato' - }); - showLoading(false); + }, name); if (result.success) { showToast(t('toast.thrown_away', { name: currentProduct.name }), 'success'); showPage('dashboard'); @@ -9196,7 +9232,6 @@ async function throwAll() { showToast(result.error || t('error.generic'), 'error'); } } catch(e) { - showLoading(false); showToast(t('error.connection'), 'error'); } }, @@ -9208,15 +9243,12 @@ async function throwPartial() { const qty = parseFloat(document.getElementById('throw-quantity').value) || 1; const loc = document.getElementById('throw-location').value; closeModal(); - showLoading(true); try { - const result = await api('inventory_use', {}, 'POST', { + const result = await _inventoryWaste({ product_id: currentProduct.id, quantity: qty, location: loc, - notes: 'Buttato' - }); - showLoading(false); + }, currentProduct.name); if (result.success) { showToast(t('toast.thrown_away_partial', { qty, unit: currentProduct.unit || 'pz', name: currentProduct.name }), 'success'); showPage('dashboard'); @@ -9224,7 +9256,6 @@ async function throwPartial() { showToast(result.error || t('error.generic'), 'error'); } } catch(e) { - showLoading(false); showToast(t('error.connection'), 'error'); } } diff --git a/scripts/github-issue-triage.php b/scripts/github-issue-triage.php new file mode 100644 index 0000000..83e95b0 --- /dev/null +++ b/scripts/github-issue-triage.php @@ -0,0 +1,81 @@ +#!/usr/bin/env php + true, + CURLOPT_HTTPHEADER => $headers, + CURLOPT_TIMEOUT => 20, + ]); + if ($method === 'PATCH') { + curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH'); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + } elseif ($method === 'POST') { + curl_setopt($ch, CURLOPT_POST, true); + curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload)); + } + $raw = curl_exec($ch); + $code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE); + curl_close($ch); + return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []]; +} + +function comment(string $token, int $num, string $body): void { + $r = ghApi($token, 'POST', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments", ['body' => $body]); + echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK comment #$num\n" : "FAIL comment #$num\n"; +} + +function closeIssue(string $token, int $num): void { + $r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'closed']); + echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK close #$num\n" : "FAIL close #$num\n"; +} + +function reopenIssue(string $token, int $num): void { + $r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'open']); + echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK reopen #$num\n" : "FAIL reopen #$num\n"; +} + +$reopen = [ + 125 => "Reopened: **voice commands in cooking mode** are not implemented yet (only TTS readout exists). This was closed by mistake during bulk triage — the feature backlog should stay open until hands-free step navigation ships.", + 98 => "Reopened: **pin favourite products to the top of inventory** is not implemented yet (recipe favourites #124 are done, but product pinning is a separate request). Closed by mistake — keeping on the backlog.", +]; + +foreach ($reopen as $num => $msg) { + comment($token, $num, $msg); + reopenIssue($token, $num); +} + +$bugs = [ + 201 => 'Fixed in latest develop: `inventory_use` and `shopping_add` now retry on `SQLITE_BUSY` via `dbWithRetry()` (same pattern as #198).', + 202 => 'Fixed: Bring/internal `shopping_add` wrapped in `dbWithRetry()` to survive cron + PWA concurrent writes.', + 203 => 'Fixed: `smartShopping()` / `smartShoppingCached()` now call `set_time_limit(120)` so large pantries no longer hit the 30s PHP fatal.', + 204 => 'Fixed: same as #203 — smart shopping timeout caused HTTP 500; extended execution limit resolves the crash.', +]; + +foreach ($bugs as $num => $msg) { + comment($token, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._"); + closeIssue($token, $num); +} + +echo "Done.\n"; diff --git a/translations/de.json b/translations/de.json index 6ae9fb1..ac909b4 100644 --- a/translations/de.json +++ b/translations/de.json @@ -353,6 +353,18 @@ "throw_all_confirm_btn": "🗑️ Ja, entsorgen", "locations_short": "Orte" }, + "waste": { + "reason_title": "Warum wirfst du es weg?", + "reason_subtitle": "Das hilft uns, ähnliche Verschwendung zu vermeiden.", + "reason_expired": "⏰ Abgelaufen", + "reason_spoiled": "🦠 Verdorben", + "reason_wrong_location": "📍 Falscher Lagerort", + "reason_kept_too_long": "⏳ Zu lange aufbewahrt", + "reason_bought_too_much": "🛒 Zu viel gekauft", + "reason_forgotten": "😴 Vergessen / nicht rechtzeitig genutzt", + "reason_bad_quality": "👎 Schlechte Qualität beim Kauf", + "reason_other": "❓ Sonstiges" + }, "product": { "title_new": "Neues Produkt", "title_edit": "Produkt bearbeiten", diff --git a/translations/en.json b/translations/en.json index e7898f3..3294cfc 100644 --- a/translations/en.json +++ b/translations/en.json @@ -353,6 +353,18 @@ "throw_all_confirm_btn": "🗑️ Yes, discard", "locations_short": "places" }, + "waste": { + "reason_title": "Why are you discarding it?", + "reason_subtitle": "This helps us prevent similar waste next time.", + "reason_expired": "⏰ Expired", + "reason_spoiled": "🦠 Spoiled / gone bad", + "reason_wrong_location": "📍 Wrong storage (fridge/freezer/pantry)", + "reason_kept_too_long": "⏳ Kept too long", + "reason_bought_too_much": "🛒 Bought too much", + "reason_forgotten": "😴 Forgotten / not used in time", + "reason_bad_quality": "👎 Poor quality when bought", + "reason_other": "❓ Other" + }, "product": { "title_new": "New Product", "title_edit": "Edit Product", diff --git a/translations/es.json b/translations/es.json index 20e257d..629d375 100644 --- a/translations/es.json +++ b/translations/es.json @@ -353,6 +353,18 @@ "throw_all_confirm_btn": "🗑️ Sí, desechar", "locations_short": "ubicaciones" }, + "waste": { + "reason_title": "¿Por qué lo tiras?", + "reason_subtitle": "Nos ayuda a evitar desperdicios similares.", + "reason_expired": "⏰ Caducado", + "reason_spoiled": "🦠 Estropeado", + "reason_wrong_location": "📍 Lugar de guardado incorrecto", + "reason_kept_too_long": "⏳ Guardado demasiado tiempo", + "reason_bought_too_much": "🛒 Comprado de más", + "reason_forgotten": "😴 Olvidado / no usado a tiempo", + "reason_bad_quality": "👎 Mala calidad al comprar", + "reason_other": "❓ Otro" + }, "product": { "title_new": "Nuevo producto", "title_edit": "Editar producto", diff --git a/translations/fr.json b/translations/fr.json index 7c2818f..35a324b 100644 --- a/translations/fr.json +++ b/translations/fr.json @@ -353,6 +353,18 @@ "throw_all_confirm_btn": "🗑️ Oui, jeter", "locations_short": "emplacements" }, + "waste": { + "reason_title": "Pourquoi le jetez-vous ?", + "reason_subtitle": "Cela nous aide à éviter des gaspillages similaires.", + "reason_expired": "⏰ Périmé", + "reason_spoiled": "🦠 Abîmé / gâté", + "reason_wrong_location": "📍 Mauvais emplacement", + "reason_kept_too_long": "⏳ Conservé trop longtemps", + "reason_bought_too_much": "🛒 Acheté en trop grande quantité", + "reason_forgotten": "😴 Oublié / pas utilisé à temps", + "reason_bad_quality": "👎 Mauvaise qualité à l'achat", + "reason_other": "❓ Autre" + }, "product": { "title_new": "Nouveau produit", "title_edit": "Modifier le produit", diff --git a/translations/it.json b/translations/it.json index 4b2e086..78fae42 100644 --- a/translations/it.json +++ b/translations/it.json @@ -353,6 +353,18 @@ "throw_all_confirm_btn": "🗑️ Sì, butta", "locations_short": "posti" }, + "waste": { + "reason_title": "Perché lo butti?", + "reason_subtitle": "Ci aiuta a evitare sprechi simili in futuro.", + "reason_expired": "⏰ Scaduto", + "reason_spoiled": "🦠 Andato a male / deperito", + "reason_wrong_location": "📍 Posto sbagliato (frigo/freezer/dispensa)", + "reason_kept_too_long": "⏳ Tenuto troppo a lungo", + "reason_bought_too_much": "🛒 Comprato troppo", + "reason_forgotten": "😴 Dimenticato / non usato in tempo", + "reason_bad_quality": "👎 Qualità scadente all'acquisto", + "reason_other": "❓ Altro" + }, "product": { "title_new": "Nuovo Prodotto", "title_edit": "Modifica Prodotto",