Fix sealed/opened expiry; AI shelf-life cache; redesign waste UI

This commit is contained in:
dadaloop82
2026-04-29 06:42:21 +00:00
parent e002955173
commit 22266cb620
4 changed files with 228 additions and 82 deletions
+24 -13
View File
@@ -155,10 +155,23 @@ function migrateDB(PDO $db): void {
// Add opened_at column to inventory if missing // Add opened_at column to inventory if missing
if (!in_array('opened_at', $invColNames)) { if (!in_array('opened_at', $invColNames)) {
$db->exec("ALTER TABLE inventory ADD COLUMN opened_at DATETIME DEFAULT NULL"); $db->exec("ALTER TABLE inventory ADD COLUMN opened_at DATETIME DEFAULT NULL");
// Backfill: detect already-opened items and set opened_at + recalculate expiry // Backfill: detect already-opened fridge items and set opened_at.
// Only frigo items — pantry/freezer fractional quantities don't imply opened.
backfillOpenedItems($db); backfillOpenedItems($db);
} }
// Migration: undo incorrect backfill for non-frigo items.
// The original backfill also tagged dispensa/freezer items as opened, which overwrote
// their manufacturer expiry_date with a short estimated value. Clear opened_at so they
// return to the sealed section; clear expiry_date so users can re-enter the real date.
$migDone = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fix_nonfrigo_opened_v1'")->fetchColumn();
if (!$migDone) {
$db->exec("UPDATE inventory SET opened_at = NULL, expiry_date = NULL
WHERE location NOT IN ('frigo') AND opened_at IS NOT NULL");
$db->exec("INSERT OR REPLACE INTO app_settings (key, value)
VALUES ('migration_fix_nonfrigo_opened_v1', '1')");
}
// Migration v2: recalculate sealed fridge item expiry (fridge extends shelf life) // Migration v2: recalculate sealed fridge item expiry (fridge extends shelf life)
$migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn(); $migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn();
if (!$migrated) { if (!$migrated) {
@@ -175,12 +188,16 @@ function migrateDB(PDO $db): void {
} }
/** /**
* Backfill opened_at for existing inventory items that appear to be opened. * Backfill opened_at for frigo items that appear to be opened.
* An item is considered opened if: * An item is considered opened if:
* - conf unit with fractional quantity * - conf unit with fractional quantity
* - weight/volume unit (g,kg,ml,l) with quantity < default_quantity * - weight/volume unit (g,kg,ml,l) with quantity < default_quantity
* Uses updated_at as the approximate opened_at date. * Uses updated_at as the approximate opened_at date.
* Recalculates expiry_date based on opened shelf life from opened_at. * Does NOT overwrite expiry_date — the manufacturer date is preserved;
* getStats computes opened expiry on-the-fly from opened_at.
*
* Only frigo items: pantry/freezer fractional quantities are normal
* (e.g. 3 of 6 UHT milks) and do not indicate a food-safety expiry change.
*/ */
function backfillOpenedItems(PDO $db): void { function backfillOpenedItems(PDO $db): void {
$stmt = $db->query(" $stmt = $db->query("
@@ -188,7 +205,7 @@ function backfillOpenedItems(PDO $db): void {
p.name, p.category, p.unit, p.default_quantity p.name, p.category, p.unit, p.default_quantity
FROM inventory i FROM inventory i
JOIN products p ON i.product_id = p.id JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0 WHERE i.quantity > 0 AND i.location = 'frigo'
"); ");
$rows = $stmt->fetchAll(); $rows = $stmt->fetchAll();
@@ -207,15 +224,9 @@ function backfillOpenedItems(PDO $db): void {
if (!$isOpened) continue; if (!$isOpened) continue;
$openedAt = $row['updated_at']; // Only set opened_at — do NOT touch expiry_date (manufacturer date is preserved)
$openedDays = estimateOpenedExpiryDaysPHP($row['name'], $row['category'], $row['location']); $upd = $db->prepare("UPDATE inventory SET opened_at = ? WHERE id = ? AND opened_at IS NULL");
if ($row['vacuum_sealed']) $openedDays = (int)round($openedDays * 1.5); $upd->execute([$row['updated_at'], $row['id']]);
// Calculate new expiry from opened_at
$newExpiry = date('Y-m-d', strtotime($openedAt . " +{$openedDays} days"));
$upd = $db->prepare("UPDATE inventory SET opened_at = ?, expiry_date = ? WHERE id = ?");
$upd->execute([$openedAt, $newExpiry, $row['id']]);
} }
} }
+96 -6
View File
@@ -340,6 +340,10 @@ try {
getFoodFacts(); getFoodFacts();
break; break;
case 'opened_shelf_life':
getOpenedShelfLifeAction();
break;
default: default:
http_response_code(404); http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]); echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -1678,16 +1682,19 @@ function getStats(PDO $db): void {
$today = strtotime('today midnight'); $today = strtotime('today midnight');
foreach ($openedRaw as $item) { foreach ($openedRaw as $item) {
$vacuum = (int)($item['vacuum_sealed'] ?? 0); $vacuum = (int)($item['vacuum_sealed'] ?? 0);
// originalExpiry = manufacturer date stored in inventory.expiry_date.
// For items correctly managed, this is the sealed expiry from the package.
$originalExpiry = !empty($item['expiry_date']) ? strtotime($item['expiry_date']) : null; $originalExpiry = !empty($item['expiry_date']) ? strtotime($item['expiry_date']) : null;
if (!empty($item['opened_at'])) { if (!empty($item['opened_at'])) {
// Compute the opened shelf-life from the moment it was opened // Compute opened shelf-life using AI (with rule-based fallback + persistent cache).
$openedDays = estimateOpenedExpiryDaysPHP($item['name'], $item['category'], $item['location']); // The vacuum-sealed multiplier is already handled inside getOpenedShelfLifeDays.
if ($vacuum) $openedDays = (int)round($openedDays * 1.5); $openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum);
$computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400; $computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400;
// Use the computed opened expiry only — stored expiry_date may have been set by // Always respect the manufacturer date: if the package expires before our estimate,
// an older (inaccurate) estimation and would give wrong results if mixed in. // use the manufacturer date (e.g., milk opened 2 days before its sealed expiry).
$finalExpiry = $computedExpiry; $finalExpiry = ($originalExpiry !== null && $originalExpiry < $computedExpiry)
? $originalExpiry : $computedExpiry;
$item['opened_expiry'] = date('Y-m-d', $finalExpiry); $item['opened_expiry'] = date('Y-m-d', $finalExpiry);
$item['days_to_expiry'] = (int)round(($finalExpiry - $today) / 86400); $item['days_to_expiry'] = (int)round(($finalExpiry - $today) / 86400);
} else { } else {
@@ -2123,6 +2130,89 @@ function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 3
return $last; return $last;
} }
// ===== AI-POWERED OPENED SHELF LIFE =====
/**
* Return the number of days a product remains safe after opening, depending on storage location.
* Checks a local JSON cache first (keyed by product name+location); on cache miss, asks Gemini AI.
* Falls back to the rule-based estimate if AI is unavailable or returns an unusable answer.
* Cache has no expiry — shelf-life science doesn't change; the file can be manually deleted to refresh.
*/
function getOpenedShelfLifeDays(string $name, string $category, string $location, bool $vacuumSealed = false): int {
$cacheFile = __DIR__ . '/../data/opened_shelf_cache.json';
$cacheKey = md5(mb_strtolower($name) . '|' . mb_strtolower($location));
// Load cache
$cache = [];
if (file_exists($cacheFile)) {
$cache = json_decode(file_get_contents($cacheFile), true) ?: [];
}
if (isset($cache[$cacheKey]['days'])) {
$days = (int)$cache[$cacheKey]['days'];
return $vacuumSealed ? (int)round($days * 1.5) : $days;
}
// Try Gemini AI
$apiKey = env('GEMINI_API_KEY');
$days = 0;
if (!empty($apiKey)) {
$locLabel = match($location) {
'frigo' => 'refrigerator (4 °C / 39 °F)',
'freezer' => 'freezer (-18 °C / 0 °F)',
default => 'pantry / room temperature (18-22 °C)',
};
$catHint = $category ? " (category: {$category})" : '';
$prompt = "How many days can \"{$name}\"{$catHint} be safely consumed after being OPENED and stored in a {$locLabel}? "
. "Reply with ONLY a single integer (the number of days). No units, no explanation, just the number.";
$payload = [
'contents' => [['parts' => [['text' => $prompt]]]],
'generationConfig' => ['maxOutputTokens' => 8, 'temperature' => 0],
];
$result = callGeminiWithFallback($apiKey, $payload, 12);
if ($result['http_code'] === 200) {
$text = trim($result['data']['candidates'][0]['content']['parts'][0]['text'] ?? '');
$parsed = (int)preg_replace('/\D/', '', $text);
if ($parsed > 0 && $parsed <= 3650) {
$days = $parsed;
}
}
}
// Fall back to rule-based estimate if AI unavailable / unusable
$source = 'rule';
if ($days <= 0) {
$days = estimateOpenedExpiryDaysPHP($name, $category, $location);
$source = 'rule';
} else {
$source = 'ai';
}
// Persist to cache
$cache[$cacheKey] = ['days' => $days, 'source' => $source, 'name' => $name, 'location' => $location, 'ts' => time()];
@file_put_contents($cacheFile, json_encode($cache, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
return $vacuumSealed ? (int)round($days * 1.5) : $days;
}
/**
* Expose the shelf-life cache via API so the JS can pre-warm it when a user marks an item opened.
* Accepts: POST { name, category, location, vacuum_sealed? }
* Returns: { days, source }
*/
function getOpenedShelfLifeAction(): void {
header('Content-Type: application/json; charset=utf-8');
$input = json_decode(file_get_contents('php://input'), true) ?? [];
$name = trim($input['name'] ?? '');
$cat = trim($input['category'] ?? '');
$loc = trim($input['location'] ?? 'frigo');
$vac = !empty($input['vacuum_sealed']);
if ($name === '') { echo json_encode(['error' => 'name required']); return; }
$days = getOpenedShelfLifeDays($name, $cat, $loc, $vac);
echo json_encode(['days' => $days]);
}
function geminiReadExpiry(): void { function geminiReadExpiry(): void {
$apiKey = env('GEMINI_API_KEY'); $apiKey = env('GEMINI_API_KEY');
if (empty($apiKey)) { if (empty($apiKey)) {
+66 -43
View File
@@ -376,22 +376,21 @@ body {
.btn-quick-recipe span:last-child { font-size: 1.1rem; opacity: 0.8; } .btn-quick-recipe span:last-child { font-size: 1.1rem; opacity: 0.8; }
.btn-quick-recipe:active { transform: scale(0.98); } .btn-quick-recipe:active { transform: scale(0.98); }
/* ── Anti-Waste Report Card ─────────────────────────────── */ /* ── Anti-Waste Report Card — same structure as .alert-section ── */
#waste-chart-section { #waste-chart-section {
background: linear-gradient(160deg, #f0fdf4 0%, var(--bg-card) 70%); background: #f0fdf4;
border: 2px solid #86efac; border: 2px solid #86efac;
border-left: 4px solid var(--success);
border-radius: var(--radius); border-radius: var(--radius);
padding: 10px 12px; padding: 16px;
margin-bottom: 10px; margin-bottom: 12px;
} }
/* Header row */ /* Header row — mirrors .alert-section h3 */
.aw-header { .aw-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
margin-bottom: 7px; margin-bottom: 10px;
} }
.aw-title-row { .aw-title-row {
display: flex; display: flex;
@@ -399,7 +398,7 @@ body {
gap: 6px; gap: 6px;
} }
.aw-title { .aw-title {
font-size: 0.88rem; font-size: 1.05rem;
font-weight: 700; font-weight: 700;
margin: 0; margin: 0;
color: var(--text); color: var(--text);
@@ -441,48 +440,71 @@ body {
.aw-grade-c { background: #fb923c; } .aw-grade-c { background: #fb923c; }
.aw-grade-d { background: #dc2626; } .aw-grade-d { background: #dc2626; }
/* ── Single-row comparison bar ──────────────────────────── */ /* ── Dual animated comparison bars ──────────────────────── */
.aw-cmp-wrap { margin-bottom: 7px; } .aw-cmp-wrap { margin-bottom: 10px; }
.aw-cmp-row-labels { .aw-cmp-bar-row {
display: flex; display: flex;
justify-content: space-between; align-items: center;
font-size: 0.72rem; gap: 8px;
margin-bottom: 3px; margin-bottom: 6px;
} }
.aw-cmp-lbl-you { color: var(--success); } .aw-cmp-bar-label {
.aw-cmp-lbl-you strong { font-size: 0.8rem; } font-size: 0.68rem;
.aw-cmp-lbl-avg { color: var(--text-light); text-align: right; } font-weight: 700;
.aw-cmp-lbl-avg strong { color: var(--text); font-size: 0.8rem; } width: 42px;
.aw-cmp-track { flex-shrink: 0;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.aw-cmp-bar-label-you { color: #16a34a; }
.aw-cmp-bar-label-avg { color: #f97316; }
.aw-cmp-bar-track {
flex: 1;
height: 14px;
background: rgba(0,0,0,.06);
border-radius: 7px;
overflow: hidden;
position: relative; position: relative;
height: 8px;
background: var(--bg-main);
border-radius: 4px;
margin-bottom: 4px;
} }
.aw-cmp-you-fill { .aw-cmp-bar-fill {
height: 100%;
border-radius: 7px;
width: 0; /* starts at 0 — animated via JS */
transition: width 1.1s cubic-bezier(.4,0,.2,1);
position: relative;
overflow: hidden;
}
/* Shimmer overlay */
.aw-cmp-bar-fill::after {
content: '';
position: absolute; position: absolute;
left: 0; top: 0; bottom: 0; inset: 0;
background: var(--success); background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.35) 50%, transparent 100%);
border-radius: 4px; animation: aw-shimmer 2.2s ease-in-out infinite;
transition: width 0.7s cubic-bezier(.4,0,.2,1); opacity: 0;
min-width: 3px; transition: opacity 0.5s 1s;
} }
.aw-cmp-avg-tick { .aw-cmp-bar-fill.loaded::after { opacity: 1; }
position: absolute; @keyframes aw-shimmer {
top: -2px; bottom: -2px; 0% { transform: translateX(-100%); }
width: 3px; 100% { transform: translateX(100%); }
background: #f59e0b;
border-radius: 2px;
box-shadow: 0 0 0 2px var(--bg-card);
transform: translateX(-50%);
transition: left 0.7s cubic-bezier(.4,0,.2,1);
} }
/* Inline status below bar */ .aw-cmp-bar-fill-you { background: linear-gradient(90deg, #4ade80, #16a34a); }
.aw-cmp-bar-fill-avg { background: linear-gradient(90deg, #fdba74, #f97316); }
.aw-cmp-bar-pct {
font-size: 0.8rem;
font-weight: 800;
width: 30px;
text-align: right;
flex-shrink: 0;
}
.aw-cmp-bar-pct-you { color: #16a34a; }
.aw-cmp-bar-pct-avg { color: #f97316; }
/* Inline status below bars */
.aw-status-inline { .aw-status-inline {
font-size: 0.72rem; font-size: 0.75rem;
font-weight: 600; font-weight: 600;
margin: 0 0 6px; margin: 2px 0 8px;
padding: 0; padding: 0;
} }
.aw-status-good { color: #16a34a; } .aw-status-good { color: #16a34a; }
@@ -494,8 +516,9 @@ body {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
overflow: hidden; overflow: hidden;
gap: 5px; justify-content: center;
margin-bottom: 7px; gap: 6px;
margin-bottom: 9px;
transition: opacity 0.38s ease; transition: opacity 0.38s ease;
} }
.aw-badge { .aw-badge {
+42 -20
View File
@@ -2208,13 +2208,13 @@ function _startAntiWasteAutoRefresh() {
} }
/** /**
* Start badge rotation: shows MAX_VISIBLE badges at a time, cycles through all * Start badge rotation: shows maxVisible badges at a time, cycles through all
* with a fade-out/fade-in every INTERVAL ms. * with a fade-out/fade-in once per hour (aligned to the next clock-hour boundary).
*/ */
function _startBadgeRotation(allBadges, maxVisible = 3) { function _startBadgeRotation(allBadges, maxVisible = 4) {
clearInterval(_awBadgeTimer); clearInterval(_awBadgeTimer);
const row = document.getElementById('aw-badges-row'); const row = document.getElementById('aw-badges-row');
if (!row || allBadges.length <= maxVisible) return; // no rotation needed if (!row || allBadges.length <= maxVisible) return;
let start = 0; let start = 0;
const render = () => { const render = () => {
@@ -2225,7 +2225,7 @@ function _startBadgeRotation(allBadges, maxVisible = 3) {
row.innerHTML = slice.join(''); row.innerHTML = slice.join('');
}; };
_awBadgeTimer = setInterval(() => { const rotate = () => {
if (!row.isConnected) { clearInterval(_awBadgeTimer); return; } if (!row.isConnected) { clearInterval(_awBadgeTimer); return; }
row.style.opacity = '0'; row.style.opacity = '0';
setTimeout(() => { setTimeout(() => {
@@ -2233,7 +2233,14 @@ function _startBadgeRotation(allBadges, maxVisible = 3) {
render(); render();
row.style.opacity = '1'; row.style.opacity = '1';
}, 380); }, 380);
}, 4500); };
// Fire once per hour, aligned to next full clock-hour
const msToNextHour = (60 - new Date().getMinutes()) * 60_000 - new Date().getSeconds() * 1000;
setTimeout(() => {
rotate();
_awBadgeTimer = setInterval(rotate, 3_600_000); // then every hour
}, msToNextHour);
} }
/** Build one trend mini-card. */ /** Build one trend mini-card. */
@@ -2303,10 +2310,11 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
statusCls = 'aw-status-ok'; statusCls = 'aw-status-ok';
} }
// Single-row compare bar // Dual animated comparison bars — scaled so the larger value fills ~88% of its track
const scale = Math.max(myRate, avgRate, 5) * 1.35; const scale = Math.max(myRate, avgRate, 1);
const youFillPct = +((myRate / scale) * 100).toFixed(1); const youPct = +((myRate / scale) * 88).toFixed(1);
const avgTickPct = +((avgRate / scale) * 100).toFixed(1); const avgPct = +((avgRate / scale) * 88).toFixed(1);
const youLabel = t('antiwaste.you').split(' ')[0]; // "Tu" / "You" / "Du"
// Trend cards // Trend cards
const totals = [usedP60 + wastedP60, usedP30 + wastedP30, total30]; const totals = [usedP60 + wastedP60, usedP30 + wastedP30, total30];
@@ -2322,7 +2330,7 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
const arr2 = _awTrendArrow(rates[1], rates[2]); const arr2 = _awTrendArrow(rates[1], rates[2]);
const arrowHtml = a => a ? `<span class="aw-tc-arrow ${a.cls}">${a.sym}</span>` : ''; const arrowHtml = a => a ? `<span class="aw-tc-arrow ${a.cls}">${a.sym}</span>` : '';
// Build all badge objects (shown 3 at a time, rotated) // Build all badge objects (shown 4 at a time, rotated every hour)
const diffPct = avgRate - myRate; const diffPct = avgRate - myRate;
const allBadges = []; const allBadges = [];
allBadges.push(`<span class="aw-badge aw-badge-rate"> allBadges.push(`<span class="aw-badge aw-badge-rate">
@@ -2350,8 +2358,8 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
<span class="aw-badge-body"><b>${diffPct}%</b><small>${t('antiwaste.badge_better')}</small></span> <span class="aw-badge-body"><b>${diffPct}%</b><small>${t('antiwaste.badge_better')}</small></span>
</span>`); </span>`);
// Initial 3-badge slice // Initial 4-badge slice (centered via CSS justify-content:center)
const MAX_VISIBLE = 3; const MAX_VISIBLE = 4;
const initBadges = allBadges.slice(0, MAX_VISIBLE).join(''); const initBadges = allBadges.slice(0, MAX_VISIBLE).join('');
// Facts // Facts
@@ -2371,13 +2379,19 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
</div> </div>
<div class="aw-cmp-wrap"> <div class="aw-cmp-wrap">
<div class="aw-cmp-row-labels"> <div class="aw-cmp-bar-row">
<span class="aw-cmp-lbl-you">${t('antiwaste.you')} <strong>${myRate}%</strong></span> <span class="aw-cmp-bar-label aw-cmp-bar-label-you">${youLabel}</span>
<span class="aw-cmp-lbl-avg"><strong>${avgRate}%</strong> ${country}</span> <div class="aw-cmp-bar-track">
<div id="aw-bar-you" class="aw-cmp-bar-fill aw-cmp-bar-fill-you"></div>
</div>
<span class="aw-cmp-bar-pct aw-cmp-bar-pct-you">${myRate}%</span>
</div> </div>
<div class="aw-cmp-track"> <div class="aw-cmp-bar-row">
<div class="aw-cmp-you-fill" style="width:${youFillPct}%"></div> <span class="aw-cmp-bar-label aw-cmp-bar-label-avg">${country}</span>
<div class="aw-cmp-avg-tick" style="left:${avgTickPct}%"></div> <div class="aw-cmp-bar-track">
<div id="aw-bar-avg" class="aw-cmp-bar-fill aw-cmp-bar-fill-avg"></div>
</div>
<span class="aw-cmp-bar-pct aw-cmp-bar-pct-avg">${avgRate}%</span>
</div> </div>
<p class="aw-status-inline ${statusCls}">${statusMsg}</p> <p class="aw-status-inline ${statusCls}">${statusMsg}</p>
</div> </div>
@@ -2400,7 +2414,15 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
<div class="aw-source">${(_awLiveFacts && _awLiveFacts.source) || t('antiwaste.source')}</div> <div class="aw-source">${(_awLiveFacts && _awLiveFacts.source) || t('antiwaste.source')}</div>
`; `;
// Badge rotation (3 at a time) // Animate comparison bars after DOM insertion
requestAnimationFrame(() => {
const barYou = document.getElementById('aw-bar-you');
const barAvg = document.getElementById('aw-bar-avg');
if (barYou) { barYou.style.width = youPct + '%'; setTimeout(() => barYou.classList.add('loaded'), 100); }
if (barAvg) { barAvg.style.width = avgPct + '%'; setTimeout(() => barAvg.classList.add('loaded'), 100); }
});
// Badge rotation: 4 at a time, every hour
_startBadgeRotation(allBadges, MAX_VISIBLE); _startBadgeRotation(allBadges, MAX_VISIBLE);
// Fact rotation (every 6 s) // Fact rotation (every 6 s)