feat: predict expiry date from product history when adding items

- PHP: new 'expiry_history' action computes avg shelf life (expiry_date - added_at)
  from inventory table for the same product_id (last 730 days, valid entries only)
- JS: _fetchExpiryHistoryAndUpdate() fires async after showAddForm() renders
  and replaces the rule-based estimate with the historical average if available
- Labeled with '📊 storico' badge on the estimate line (tooltip shows sample count)
- recalculateAddExpiry() and selectPurchaseType('new') both honour window._historyExpiryDays
- Vacuum-sealed multiplier still applied on top of historical base
- Falls back silently to rule-based estimateExpiryDays when no history exists
This commit is contained in:
dadaloop82
2026-04-06 09:09:04 +00:00
parent 568cc1e6fa
commit 50da545c72
3 changed files with 88 additions and 7 deletions
+34
View File
@@ -194,6 +194,10 @@ try {
ttsProxy();
break;
case 'expiry_history':
getExpiryHistory($db);
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -255,6 +259,36 @@ function ttsProxy() {
// ===== CLIENT LOG =====
// ===== EXPIRY HISTORY =====
function getExpiryHistory($db): void {
$productId = (int)($_GET['product_id'] ?? $_POST['product_id'] ?? 0);
if (!$productId) {
echo json_encode(['avg_days' => null, 'count' => 0]);
return;
}
// Compute average shelf life (expiry_date - added_at) for this product
// Only use entries where expiry_date is clearly in the future relative to added_at
$stmt = $db->prepare("
SELECT ROUND(AVG(CAST(JULIANDAY(expiry_date) - JULIANDAY(added_at) AS REAL))) AS avg_days,
COUNT(*) AS count
FROM inventory
WHERE product_id = ?
AND expiry_date IS NOT NULL
AND expiry_date > date(added_at)
AND added_at >= date('now', '-730 days')
");
$stmt->execute([$productId]);
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$row || !$row['count'] || $row['avg_days'] === null) {
echo json_encode(['avg_days' => null, 'count' => 0]);
return;
}
echo json_encode(['avg_days' => (int)$row['avg_days'], 'count' => (int)$row['count']]);
}
function clientLog(): void {
$input = json_decode(file_get_contents('php://input'), true);
$logFile = __DIR__ . '/../data/client_debug.log';
+11
View File
@@ -2531,6 +2531,17 @@ body {
font-weight: 600;
}
.history-badge {
font-size: 0.75rem;
background: var(--primary);
color: #fff;
border-radius: 10px;
padding: 1px 7px;
vertical-align: middle;
font-weight: 500;
cursor: default;
}
.form-hint {
font-size: 0.8rem;
color: var(--text-muted);
+43 -7
View File
@@ -3231,6 +3231,9 @@ function showAddForm() {
vacuumCb.checked = false;
document.getElementById('add-vacuum-hint').style.display = 'none';
}
// Reset historical expiry for this product; will be fetched async
window._historyExpiryDays = null;
window._historyExpiryCount = 0;
// Store base expiry for vacuum recalculation
window._addBaseExpiryDays = estimatedDays;
@@ -3258,6 +3261,10 @@ function showAddForm() {
`;
showPage('add');
// After rendering, fetch history-based expiry prediction
if (currentProduct && currentProduct.id) {
_fetchExpiryHistoryAndUpdate(currentProduct.id);
}
}
function toggleVacuumSealed() {
@@ -3277,16 +3284,17 @@ function recalculateAddExpiry() {
const loc = document.getElementById('add-location')?.value || '';
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
let days = estimateExpiryDays(currentProduct, loc);
if (isVacuum) days = getVacuumExpiryDays(days);
const baseDays = window._historyExpiryDays ?? estimateExpiryDays(currentProduct, loc);
let days = isVacuum ? getVacuumExpiryDays(baseDays) : baseDays;
window._addBaseExpiryDays = estimateExpiryDays(currentProduct, loc);
window._addBaseExpiryDays = baseDays;
const newDate = addDays(days);
const newLabel = formatEstimatedExpiry(days);
let suffix = '';
if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)';
if (window._historyExpiryDays) suffix = ' (da storico)';
else if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)';
else if (loc === 'freezer') suffix = ' (freezer)';
else if (isVacuum) suffix = ' (sotto vuoto)';
@@ -3298,6 +3306,33 @@ function recalculateAddExpiry() {
if (dateEl) dateEl.textContent = formatDate(newDate);
}
async function _fetchExpiryHistoryAndUpdate(productId) {
try {
const res = await fetch(`api/index.php?action=expiry_history&product_id=${encodeURIComponent(productId)}`);
const data = await res.json();
if (data.avg_days && data.avg_days > 0 && data.count >= 1) {
window._historyExpiryDays = data.avg_days;
window._historyExpiryCount = data.count;
// Update the displayed date and label
const loc = document.getElementById('add-location')?.value || '';
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
let days = isVacuum ? getVacuumExpiryDays(data.avg_days) : data.avg_days;
const newDate = addDays(days);
const newLabel = formatEstimatedExpiry(days);
const suffix = ` <span class="history-badge" title="Media da ${data.count} insertiment${data.count === 1 ? 'o' : 'i'} precedent${data.count === 1 ? 'e' : 'i'}">📊 storico</span>`;
const expiryInput = document.getElementById('add-expiry');
const estimateEl = document.querySelector('.expiry-estimate-label');
const dateEl = document.querySelector('.expiry-estimate-date');
if (expiryInput) expiryInput.value = newDate;
if (estimateEl) estimateEl.innerHTML = `Scadenza stimata: <strong>${newLabel}${suffix}</strong>`;
if (dateEl) dateEl.textContent = formatDate(newDate);
window._addBaseExpiryDays = data.avg_days;
}
} catch (e) {
// silently fall back to rule-based estimate
}
}
function getVacuumExpiryDays(baseDays) {
// Vacuum sealing extends shelf life significantly
if (baseDays <= 7) return Math.round(baseDays * 3); // very fresh: 3x (e.g., 3→9, 7→21)
@@ -3392,12 +3427,13 @@ function selectPurchaseType(btn, type) {
// Recalculate fresh expiry based on current location/vacuum
const loc = document.getElementById('add-location')?.value || '';
const isVacuum = document.getElementById('add-vacuum-sealed')?.checked;
let days = estimateExpiryDays(currentProduct, loc);
if (isVacuum) days = getVacuumExpiryDays(days);
const baseDays = window._historyExpiryDays ?? estimateExpiryDays(currentProduct, loc);
let days = isVacuum ? getVacuumExpiryDays(baseDays) : baseDays;
const estimatedDate = addDays(days);
const estimateLabel = formatEstimatedExpiry(days);
let suffix = '';
if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)';
if (window._historyExpiryDays) suffix = ` <span class="history-badge" title="Media da ${window._historyExpiryCount} inserimento/i precedente/i">📊 storico</span>`;
else if (loc === 'freezer' && isVacuum) suffix = ' (freezer + sotto vuoto)';
else if (loc === 'freezer') suffix = ' (freezer)';
else if (isVacuum) suffix = ' (sotto vuoto)';