Merge branch 'develop'

This commit is contained in:
dadaloop82
2026-04-29 06:42:24 +00:00
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
if (!in_array('opened_at', $invColNames)) {
$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);
}
// 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)
$migrated = $db->query("SELECT value FROM app_settings WHERE key = 'migration_fridge_expiry_v1'")->fetchColumn();
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:
* - conf unit with fractional quantity
* - weight/volume unit (g,kg,ml,l) with quantity < default_quantity
* 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 {
$stmt = $db->query("
@@ -188,7 +205,7 @@ function backfillOpenedItems(PDO $db): void {
p.name, p.category, p.unit, p.default_quantity
FROM inventory i
JOIN products p ON i.product_id = p.id
WHERE i.quantity > 0
WHERE i.quantity > 0 AND i.location = 'frigo'
");
$rows = $stmt->fetchAll();
@@ -207,15 +224,9 @@ function backfillOpenedItems(PDO $db): void {
if (!$isOpened) continue;
$openedAt = $row['updated_at'];
$openedDays = estimateOpenedExpiryDaysPHP($row['name'], $row['category'], $row['location']);
if ($row['vacuum_sealed']) $openedDays = (int)round($openedDays * 1.5);
// 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']]);
// Only set opened_at — do NOT touch expiry_date (manufacturer date is preserved)
$upd = $db->prepare("UPDATE inventory SET opened_at = ? WHERE id = ? AND opened_at IS NULL");
$upd->execute([$row['updated_at'], $row['id']]);
}
}
+96 -6
View File
@@ -340,6 +340,10 @@ try {
getFoodFacts();
break;
case 'opened_shelf_life':
getOpenedShelfLifeAction();
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -1678,16 +1682,19 @@ function getStats(PDO $db): void {
$today = strtotime('today midnight');
foreach ($openedRaw as $item) {
$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;
if (!empty($item['opened_at'])) {
// Compute the opened shelf-life from the moment it was opened
$openedDays = estimateOpenedExpiryDaysPHP($item['name'], $item['category'], $item['location']);
if ($vacuum) $openedDays = (int)round($openedDays * 1.5);
// Compute opened shelf-life using AI (with rule-based fallback + persistent cache).
// The vacuum-sealed multiplier is already handled inside getOpenedShelfLifeDays.
$openedDays = getOpenedShelfLifeDays($item['name'], $item['category'], $item['location'], (bool)$vacuum);
$computedExpiry = strtotime($item['opened_at']) + $openedDays * 86400;
// Use the computed opened expiry only — stored expiry_date may have been set by
// an older (inaccurate) estimation and would give wrong results if mixed in.
$finalExpiry = $computedExpiry;
// Always respect the manufacturer date: if the package expires before our estimate,
// use the manufacturer date (e.g., milk opened 2 days before its sealed expiry).
$finalExpiry = ($originalExpiry !== null && $originalExpiry < $computedExpiry)
? $originalExpiry : $computedExpiry;
$item['opened_expiry'] = date('Y-m-d', $finalExpiry);
$item['days_to_expiry'] = (int)round(($finalExpiry - $today) / 86400);
} else {
@@ -2123,6 +2130,89 @@ function callGeminiWithFallback(string $apiKey, array $payload, int $timeout = 3
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 {
$apiKey = env('GEMINI_API_KEY');
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:active { transform: scale(0.98); }
/* ── Anti-Waste Report Card ─────────────────────────────── */
/* ── Anti-Waste Report Card — same structure as .alert-section ── */
#waste-chart-section {
background: linear-gradient(160deg, #f0fdf4 0%, var(--bg-card) 70%);
background: #f0fdf4;
border: 2px solid #86efac;
border-left: 4px solid var(--success);
border-radius: var(--radius);
padding: 10px 12px;
margin-bottom: 10px;
padding: 16px;
margin-bottom: 12px;
}
/* Header row */
/* Header row — mirrors .alert-section h3 */
.aw-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 7px;
margin-bottom: 10px;
}
.aw-title-row {
display: flex;
@@ -399,7 +398,7 @@ body {
gap: 6px;
}
.aw-title {
font-size: 0.88rem;
font-size: 1.05rem;
font-weight: 700;
margin: 0;
color: var(--text);
@@ -441,48 +440,71 @@ body {
.aw-grade-c { background: #fb923c; }
.aw-grade-d { background: #dc2626; }
/* ── Single-row comparison bar ──────────────────────────── */
.aw-cmp-wrap { margin-bottom: 7px; }
.aw-cmp-row-labels {
/* ── Dual animated comparison bars ──────────────────────── */
.aw-cmp-wrap { margin-bottom: 10px; }
.aw-cmp-bar-row {
display: flex;
justify-content: space-between;
font-size: 0.72rem;
margin-bottom: 3px;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.aw-cmp-lbl-you { color: var(--success); }
.aw-cmp-lbl-you strong { font-size: 0.8rem; }
.aw-cmp-lbl-avg { color: var(--text-light); text-align: right; }
.aw-cmp-lbl-avg strong { color: var(--text); font-size: 0.8rem; }
.aw-cmp-track {
.aw-cmp-bar-label {
font-size: 0.68rem;
font-weight: 700;
width: 42px;
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;
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;
left: 0; top: 0; bottom: 0;
background: var(--success);
border-radius: 4px;
transition: width 0.7s cubic-bezier(.4,0,.2,1);
min-width: 3px;
inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.35) 50%, transparent 100%);
animation: aw-shimmer 2.2s ease-in-out infinite;
opacity: 0;
transition: opacity 0.5s 1s;
}
.aw-cmp-avg-tick {
position: absolute;
top: -2px; bottom: -2px;
width: 3px;
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);
.aw-cmp-bar-fill.loaded::after { opacity: 1; }
@keyframes aw-shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
/* 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 {
font-size: 0.72rem;
font-size: 0.75rem;
font-weight: 600;
margin: 0 0 6px;
margin: 2px 0 8px;
padding: 0;
}
.aw-status-good { color: #16a34a; }
@@ -494,8 +516,9 @@ body {
display: flex;
flex-wrap: nowrap;
overflow: hidden;
gap: 5px;
margin-bottom: 7px;
justify-content: center;
gap: 6px;
margin-bottom: 9px;
transition: opacity 0.38s ease;
}
.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
* with a fade-out/fade-in every INTERVAL ms.
* Start badge rotation: shows maxVisible badges at a time, cycles through all
* 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);
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;
const render = () => {
@@ -2225,7 +2225,7 @@ function _startBadgeRotation(allBadges, maxVisible = 3) {
row.innerHTML = slice.join('');
};
_awBadgeTimer = setInterval(() => {
const rotate = () => {
if (!row.isConnected) { clearInterval(_awBadgeTimer); return; }
row.style.opacity = '0';
setTimeout(() => {
@@ -2233,7 +2233,14 @@ function _startBadgeRotation(allBadges, maxVisible = 3) {
render();
row.style.opacity = '1';
}, 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. */
@@ -2303,10 +2310,11 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
statusCls = 'aw-status-ok';
}
// Single-row compare bar
const scale = Math.max(myRate, avgRate, 5) * 1.35;
const youFillPct = +((myRate / scale) * 100).toFixed(1);
const avgTickPct = +((avgRate / scale) * 100).toFixed(1);
// Dual animated comparison bars — scaled so the larger value fills ~88% of its track
const scale = Math.max(myRate, avgRate, 1);
const youPct = +((myRate / scale) * 88).toFixed(1);
const avgPct = +((avgRate / scale) * 88).toFixed(1);
const youLabel = t('antiwaste.you').split(' ')[0]; // "Tu" / "You" / "Du"
// Trend cards
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 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 allBadges = [];
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>`);
// Initial 3-badge slice
const MAX_VISIBLE = 3;
// Initial 4-badge slice (centered via CSS justify-content:center)
const MAX_VISIBLE = 4;
const initBadges = allBadges.slice(0, MAX_VISIBLE).join('');
// Facts
@@ -2371,13 +2379,19 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
</div>
<div class="aw-cmp-wrap">
<div class="aw-cmp-row-labels">
<span class="aw-cmp-lbl-you">${t('antiwaste.you')} <strong>${myRate}%</strong></span>
<span class="aw-cmp-lbl-avg"><strong>${avgRate}%</strong> ${country}</span>
<div class="aw-cmp-bar-row">
<span class="aw-cmp-bar-label aw-cmp-bar-label-you">${youLabel}</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 class="aw-cmp-track">
<div class="aw-cmp-you-fill" style="width:${youFillPct}%"></div>
<div class="aw-cmp-avg-tick" style="left:${avgTickPct}%"></div>
<div class="aw-cmp-bar-row">
<span class="aw-cmp-bar-label aw-cmp-bar-label-avg">${country}</span>
<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>
<p class="aw-status-inline ${statusCls}">${statusMsg}</p>
</div>
@@ -2400,7 +2414,15 @@ function _renderAntiWasteSection(used30, wasted30, usedP30, wastedP30, usedP60,
<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);
// Fact rotation (every 6 s)