From 850c5047b81afd0c923e1ac9f56f95b19bc85e8c Mon Sep 17 00:00:00 2001 From: dadaloop82 Date: Fri, 15 May 2026 11:41:29 +0000 Subject: [PATCH] Fix noisy consumption alerts and make predictions adaptive --- README.md | 1 + api/index.php | 37 +++++++++++++++++++++++++++++-------- translations/de.json | 8 ++++---- translations/en.json | 10 +++++----- translations/it.json | 10 +++++----- 5 files changed, 44 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index addb521..b0ae7f6 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ - **Banner aperto vs scaduto** — I prodotti con `opened_at` mostrano "Aperto da N giorni in [posizione]" invece di "Scaduto!", con la posizione (frigo/dispensa/freezer) esplicitamente indicata. - **Shelf life latte UHT** — Il latte generico è ora trattato come UHT (7 giorni dopo apertura) invece che fresco (4 giorni). - **Niente più false anomalie di consumo** — Il rilevatore ora ignora i casi in cui `expected = 0` (prodotto probabilmente ricomprato) e alza la soglia "more than expected" al 400%. Le notifiche rimangono solo per consumi significativamente inferiori al previsto. +- **Previsioni consumo adattive e meno rumorose** — Le previsioni ora pesano anche il comportamento recente dopo l'ultimo rifornimento; i banner "più del previsto" non vengono più mostrati (spesso erano falsi positivi), mentre i casi "meno del previsto" appaiono solo con evidenza sufficiente. - **Scaduti nascondono prodotti già buttati** — La sezione scaduti ora filtra correttamente i prodotti con `quantity = 0`. - **Docker: fix permessi DB al primo avvio** — `_ensureDataDir()` crea la directory `data/` se mancante e tenta `chmod(0775)` se non scrivibile, risolvendo `SQLSTATE[HY000][14]` su volumi Docker freschi. - **AI price estimation for shopping list** — Each Bring! shopping item now shows an estimated retail price badge (unit price + total). Prices are fetched via Gemini AI, cached server-side for 3 months, and stored client-side in `sessionStorage` to survive navigation. The dashboard shopping stat card shows a live green `ca. €X.XX` badge that updates in real-time as prices are calculated — even in background when you're on another tab. diff --git a/api/index.php b/api/index.php index a3bbfa2..e676fbd 100644 --- a/api/index.php +++ b/api/index.php @@ -2132,9 +2132,9 @@ function getConsumptionPredictions(PDO $db): void { $daySpan = ($lastDate - $firstDate) / 86400; // If all transactions are clustered within a week, the rate is unreliable if ($daySpan < 7) continue; - $dailyRate = $totalUsed / $daySpan; + $historicalRate = $totalUsed / $daySpan; - if ($dailyRate < 0.01) continue; // negligible consumption + if ($historicalRate < 0.01) continue; // negligible consumption // Get the most recent restock (last 'in' transaction) $lastIn = $db->prepare(" @@ -2166,16 +2166,35 @@ function getConsumptionPredictions(PDO $db): void { $baselineQty = floatval($item['quantity']) + $usedSinceRestock; $daysSinceRestock = max(1, (time() - $restockDate) / 86400); + // Recalculate the expected consumption with an adaptive rate: + // blend long-term history with post-restock behavior when available. + $txSinceRestock = 0; + foreach ($rows as $r) { + if (strtotime($r['created_at']) >= $restockDate) $txSinceRestock++; + } + $observedRate = $daysSinceRestock > 0 ? ($usedSinceRestock / $daysSinceRestock) : 0; + $dailyRate = $historicalRate; + if ($observedRate > 0) { + if ($txSinceRestock >= 3) { + $dailyRate = ($historicalRate * 0.45) + ($observedRate * 0.55); + } elseif ($txSinceRestock >= 1) { + $dailyRate = ($historicalRate * 0.70) + ($observedRate * 0.30); + } + } + // If the model predicts you should have consumed less than 15% of baseline // in this period, the daily rate is too low to make reliable predictions: // any single normal use will look like an anomaly. Skip it. $predictedConsumption = $dailyRate * $daysSinceRestock; if ($baselineQty > 0 && $predictedConsumption < $baselineQty * 0.15) continue; - // Predicted remaining qty = baseline - (daily rate * days since restock) + // Predicted remaining qty = baseline - (adaptive daily rate * days since restock) $expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock)); $actualQty = floatval($item['quantity']); + // Need at least some post-restock usage observations before warning. + if ($txSinceRestock < 2) continue; + // Flag if deviation > 30% and absolute diff > meaningful threshold $deviation = abs($actualQty - $expectedQty); $threshold = max($dailyRate * 3, 0.5); // at least 3 days worth or 0.5 units @@ -2188,10 +2207,12 @@ function getConsumptionPredictions(PDO $db): void { $pctDev = $expectedQty > 0 ? ($deviation / $expectedQty) : ($actualQty > 0 ? 1 : 0); - // "more than expected" is almost always a restock the model doesn't know about yet. - // Only flag it at very high deviation (>400%) to catch truly impossible values. - // "less than expected" is more actionable: user may have consumed without registering. - $flagThreshold = ($actualQty > $expectedQty) ? 4.0 : 0.30; + // "More than expected" usually means slower real consumption, not bad data. + // Suppress this direction to avoid noisy/accusatory banners. + if ($actualQty > $expectedQty) continue; + + // Only keep meaningful "less than expected" deviations. + $flagThreshold = 0.45; if ($pctDev > $flagThreshold && $deviation > $threshold) { $unit = $item['unit']; @@ -2220,7 +2241,7 @@ function getConsumptionPredictions(PDO $db): void { 'daily_rate' => round($dailyRate, 3), 'deviation_pct' => round($pctDev * 100), 'days_since_restock' => (int)round($daysSinceRestock), - 'direction' => $actualQty > $expectedQty ? 'more' : 'less', + 'direction' => 'less', 'tx_count' => count($rows), ]; } diff --git a/translations/de.json b/translations/de.json index e606cb6..dfdd7ba 100644 --- a/translations/de.json +++ b/translations/de.json @@ -91,8 +91,8 @@ "banner_review_action_edit": "Korrigieren", "banner_review_action_weigh": "Wiegen", "banner_review_dismiss": "Ignorieren", - "banner_prediction_title": "Ungewöhnlicher Verbrauch", - "banner_prediction_hint": "Laut Vorhersage stimmt diese Menge nicht mit dem erwarteten Verbrauch überein.", + "banner_prediction_title": "Verbrauch zur Prüfung", + "banner_prediction_hint": "Die Verbrauchsschätzung passt sich aktuellen Daten an: bitte nur die aktuelle Menge bestätigen.", "banner_prediction_action_confirm": "{qty} {unit} bestätigen", "banner_prediction_action_weigh": "Jetzt wiegen", "banner_prediction_action_edit": "Menge aktualisieren", @@ -128,8 +128,8 @@ "banner_prediction_rate_day": "Durchschnitt ~{n} {unit}/Tag", "banner_prediction_rate_week": "Durchschnitt ~{n} {unit}/Woche", "banner_prediction_days_ago": "Vor {n} Tagen aufgefüllt", - "banner_prediction_more": "Ich erwartete {expected} {unit}{time}, du hast aber {actual} {unit}. Hast du Bestand ohne Buchung hinzugefügt?", - "banner_prediction_less": "Ich erwartete {expected} {unit}{time}, du hast aber nur {actual} {unit}. Hast du mehr als üblich verbraucht?", + "banner_prediction_more": "frühere Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}.", + "banner_prediction_less": "Schätzung: {expected} {unit}{time}; aktuelle Menge: {actual} {unit}. Wenn sich dein Verbrauch geändert hat, aktualisiert sich die Prognose automatisch.", "banner_finished_zero": "Bestand zeigt null, aber gespeicherte Buchungen deuten an, dass es nicht leer sein sollte.", "banner_finished_expected": "Laut Aufzeichnungen solltest du noch {qty} {unit} haben.", "banner_finished_check": "Kannst du nachschauen?", diff --git a/translations/en.json b/translations/en.json index cbd8f98..303f6d7 100644 --- a/translations/en.json +++ b/translations/en.json @@ -91,9 +91,9 @@ "banner_review_action_edit": "Correct", "banner_review_action_weigh": "Weigh", "banner_review_dismiss": "Dismiss", - "banner_prediction_title": "Anomalous consumption", - "banner_prediction_hint": "Based on predictions, this quantity doesn't match expected consumption.", - "banner_prediction_action_confirm": "Confirm {qty} {unit} is correct", + "banner_prediction_title": "Consumption to review", + "banner_prediction_hint": "The consumption estimate adapts to recent data: confirm only if the current quantity is correct.", + "banner_prediction_action_confirm": "Confirm {qty} {unit}", "banner_prediction_action_weigh": "Weigh now", "banner_prediction_action_edit": "Update quantity", "banner_expired_title": "Expired product", @@ -128,8 +128,8 @@ "banner_prediction_rate_day": "Average ~{n} {unit}/day", "banner_prediction_rate_week": "Average ~{n} {unit}/week", "banner_prediction_days_ago": "{n} days ago you restocked", - "banner_prediction_more": "I expected {expected} {unit}{time}, but you have {actual} {unit}. Did you add stock without recording it?", - "banner_prediction_less": "I expected {expected} {unit}{time}, but you only have {actual} {unit}. Did you use more than usual?", + "banner_prediction_more": "previous estimate: {expected} {unit}{time}; current quantity: {actual} {unit}.", + "banner_prediction_less": "estimate: {expected} {unit}{time}; current quantity: {actual} {unit}. If your usage pace changed, the forecast updates automatically.", "banner_finished_zero": "Inventory shows zero, but recorded movements suggest it shouldn't be empty.", "banner_finished_expected": "According to records you should still have {qty} {unit}.", "banner_finished_check": "Can you check?", diff --git a/translations/it.json b/translations/it.json index 264303f..8844ff3 100644 --- a/translations/it.json +++ b/translations/it.json @@ -91,9 +91,9 @@ "banner_review_action_edit": "Correggi", "banner_review_action_weigh": "Pesa", "banner_review_dismiss": "Ignora", - "banner_prediction_title": "Consumo anomalo", - "banner_prediction_hint": "Secondo le previsioni, questa quantità non corrisponde al consumo previsto.", - "banner_prediction_action_confirm": "Confermo la quantità di {qty} {unit}", + "banner_prediction_title": "Consumo da verificare", + "banner_prediction_hint": "La stima di consumo si adatta ai dati recenti: verifica solo se la quantità corrente è corretta.", + "banner_prediction_action_confirm": "Confermo {qty} {unit}", "banner_prediction_action_weigh": "Pesa ora", "banner_prediction_action_edit": "Aggiorna quantità", "banner_expired_title": "Prodotto scaduto", @@ -128,8 +128,8 @@ "banner_prediction_rate_day": "Media ~{n} {unit}/giorno", "banner_prediction_rate_week": "Media ~{n} {unit}/settimana", "banner_prediction_days_ago": "{n} giorni fa hai rifornito", - "banner_prediction_more": "mi aspettavo {expected} {unit}{time}, ne hai invece {actual} {unit}. Hai aggiunto scorte senza registrarle?", - "banner_prediction_less": "mi aspettavo {expected} {unit}{time}, ne hai solo {actual} {unit}. Hai consumato di più del solito?", + "banner_prediction_more": "stima precedente: {expected} {unit}{time}; quantità attuale: {actual} {unit}.", + "banner_prediction_less": "stima: {expected} {unit}{time}; quantità attuale: {actual} {unit}. Se hai cambiato ritmo d'uso, la previsione si aggiorna automaticamente.", "banner_finished_zero": "L'inventario segna zero, ma i movimenti registrati dicono che non dovrebbe essere finito.", "banner_finished_expected": "Secondo le registrazioni dovresti averne ancora {qty} {unit}.", "banner_finished_check": "Puoi controllare?",