Compare commits

...

9 Commits

Author SHA1 Message Date
dadaloop82 a6478b20e1 release: v1.7.31 2026-05-29 06:46:40 +00:00
dadaloop82 223457bbdf fix: addToInventory creates new row when all existing rows are opened
When adding a new pack of a product that already has an opened row
in inventory (opened_at IS NOT NULL), the previous code merged the
new stock into the opened row, corrupting opened_at tracking and
hiding the second pack from the anomaly model.

Now: search only for sealed rows (opened_at IS NULL) to merge into.
If only opened rows exist, INSERT a new sealed row instead.
2026-05-29 06:46:37 +00:00
dadaloop82 12c6a8977a release: v1.7.30 2026-05-29 06:37:52 +00:00
dadaloop82 c7a69d8379 fix: consumption anomaly ignores sealed packs in other rows
getConsumptionPredictions now aggregates total qty across all
inventory rows for the same product_id before flagging.
If totalQtyAllRows >= expectedQty, the anomaly is suppressed
(stock is healthy, just split across opened+sealed rows).
Also uses aggregated total as the displayed actual_qty.
2026-05-29 06:37:50 +00:00
dadaloop82 c7f3c95d75 release: v1.7.29 2026-05-29 06:34:50 +00:00
dadaloop82 a6f90a07e5 feat: buy-cycle consumption prediction for untracked products
Products like salt/spices that are never marked per-use now get
consumption rate estimated from the average time between restocks:
  avgCycleDays = (lastIn - firstIn) / (buyCount - 1)
  estimatedDaysLeft = avgCycleDays - daysSinceLastBuy

Requirements: buyCount >= 3, dailyRate == 0, avgCycle >= 7 days.
Appears in smart shopping list with reason 'Finisce tra ~Ngg (ciclo medio Mgg)'.
Also marks buy-cycle products as isRegular so stock checks apply.
2026-05-29 06:34:40 +00:00
dadaloop82 2d07001c5b release: v1.7.28 2026-05-29 06:02:53 +00:00
dadaloop82 faa55eda93 chore: CHANGELOG v1.7.28 2026-05-29 06:02:51 +00:00
dadaloop82 0b902d7c19 fix: issue reporter — local FP cache prevents duplicate issues
- Add _getFpCachePath(), _loadFpCache(), _saveFpCache() helpers
- Check data/reported_issue_fps.json before GitHub Search API
  (falls back to /tmp/ when data/ is not writable)
- Save new issue number to cache immediately after creation
- Apply 30-minute comment throttle per fingerprint
- Fall back to GitHub Search on first run / cache miss

Fixes root cause of ~50 duplicate issues (#134 duplicates #135-#183)
caused by GitHub Search API indexing delay.
2026-05-29 06:02:27 +00:00
2 changed files with 158 additions and 17 deletions
+23
View File
@@ -11,6 +11,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap. - **Recipe scraps tips** — During cooking steps, detect "waste" generated (peels, cores, bones, eggshells, coffee grounds, citrus zest, etc.) and surface AI-powered tips on how to reuse them (compost, natural cleaner, broth, candied peel, etc.). Could be shown as an optional collapsible hint card below the step that generates the scrap.
## [1.7.31] - 2026-05-29
### Fixed
- **New pack merges into opened pack on add** — `addToInventory` was looking for ANY existing row for the same product+location and adding the new quantity to it. This caused a newly purchased sealed pack to be silently merged with an already-opened pack, collapsing two physically distinct containers into one row and corrupting the `opened_at` timestamp. The fix now searches only for a **sealed** (unopened) row (`opened_at IS NULL`) to merge into. If only opened rows exist, a new sealed row is created instead — keeping the two packs separate and allowing the anomaly model and shelf-life tracker to work correctly.
## [1.7.30] - 2026-05-29
### Fixed
- **False consumption anomaly with multi-row stock** — The anomaly detection banner was evaluating each inventory row in isolation. Products split across multiple rows (e.g. one opened pack with 1 pz + one sealed pack with 6 pz) incorrectly triggered a "consumed faster than expected" warning because only the opened row (1 pz) was compared against the model. The check now aggregates the total quantity across all rows for the same product before deciding to flag an anomaly. If the combined total ≥ expected remaining, the anomaly is suppressed.
## [1.7.29] - 2026-05-29
### Added
- **Buy-cycle consumption prediction** — Products that are never tracked per-use (salt, spices, cleaning supplies, etc.) now use the average time between restocks as a proxy for consumption rate. When a product has ≥ 3 purchase events and no individual `out` events, EverShelf calculates the average buy cycle (`(lastBuy - firstBuy) / (buyCount - 1)`) and estimates how many days of stock remain in the current cycle. The product appears in the smart shopping list with a reason like "Finisce tra ~12gg (ciclo medio 75gg)" before it runs out, rather than only after. These products are now also treated as `isRegular` so all stock-level urgency checks apply correctly.
## [1.7.28] - 2026-05-30
### Fixed
- **Duplicate auto-reported issues** — The GitHub issue reporter was relying solely on the GitHub Search API for deduplication. Because search indexing has a several-minutes lag, rapid error recurrences each created a new issue before the previous one was indexed, producing ~50 duplicate issues. The reporter now uses a local file cache (`data/reported_issue_fps.json`, with `/tmp/` fallback when `data/` is not writable) as the primary deduplication store. A 30-minute per-fingerprint comment throttle is also applied to prevent flooding an existing issue. GitHub Search is used only on first run or after a cache miss. Closes [#134](https://github.com/dadaloop82/EverShelf/issues/134) (and all duplicates #135#183).
## [1.7.27] - 2026-05-29 ## [1.7.27] - 2026-05-29
### Added ### Added
+135 -17
View File
@@ -2634,19 +2634,26 @@ function addToInventory(PDO $db): void {
$vacuumSealed = (int)($input['vacuum_sealed'] ?? 0); $vacuumSealed = (int)($input['vacuum_sealed'] ?? 0);
// Check if product already exists in this location // Check if a SEALED (not yet opened) row exists for this product+location.
$stmt = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ?"); // We merge new stock into a sealed row only — never into an already-opened
// pack, because that would conflate two physically distinct containers and
// corrupt the opened_at timestamp tracking.
$stmt = $db->prepare("
SELECT id, quantity FROM inventory
WHERE product_id = ? AND location = ? AND opened_at IS NULL
ORDER BY added_at ASC LIMIT 1
");
$stmt->execute([$productId, $location]); $stmt->execute([$productId, $location]);
$existing = $stmt->fetch(); $existing = $stmt->fetch();
if ($existing) { if ($existing) {
// Update quantity // Merge into the existing sealed row
$newQty = $existing['quantity'] + $quantity; $newQty = $existing['quantity'] + $quantity;
$stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?"); $stmt = $db->prepare("UPDATE inventory SET quantity = ?, expiry_date = COALESCE(?, expiry_date), vacuum_sealed = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?");
$stmt->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]); $stmt->execute([$newQty, $expiry, $vacuumSealed, $existing['id']]);
} else { } else {
$newQty = $quantity; $newQty = $quantity;
// Insert new inventory entry // All existing rows (if any) are opened packs — insert a new sealed row
$stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)"); $stmt = $db->prepare("INSERT INTO inventory (product_id, location, quantity, expiry_date, vacuum_sealed) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]); $stmt->execute([$productId, $location, $quantity, $expiry, $vacuumSealed]);
} }
@@ -4155,6 +4162,24 @@ function getConsumptionPredictions(PDO $db): void {
$expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock)); $expectedQty = max(0, $baselineQty - ($dailyRate * $daysSinceRestock));
$actualQty = floatval($item['quantity']); $actualQty = floatval($item['quantity']);
// Aggregate total stock for this product across ALL inventory rows.
// A product may be split into multiple rows (e.g. one opened pack + one
// sealed pack at a different location). The opened row alone may look
// depleted while the total is healthy — do not flag in that case.
$totalQtyStmt = $db->prepare("
SELECT COALESCE(SUM(quantity), 0)
FROM inventory
WHERE product_id = ? AND quantity > 0
");
$totalQtyStmt->execute([$pid]);
$totalQtyAllRows = floatval($totalQtyStmt->fetchColumn() ?: 0);
// If the aggregate total is above the expected remaining, the "depletion"
// is just stock spread across rows — suppress the anomaly.
if ($totalQtyAllRows >= $expectedQty) continue;
// Use the aggregate total as the visible actual qty so the banner shows
// the real combined stock, not just the single opened row.
$actualQty = $totalQtyAllRows;
// Need at least some post-restock usage observations before warning. // Need at least some post-restock usage observations before warning.
if ($txSinceRestock < 2) continue; if ($txSinceRestock < 2) continue;
@@ -8813,6 +8838,29 @@ function smartShopping(PDO $db): void {
$dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0; $dailyRate = $effectiveDays < 999 && $totalUsed > 0 ? $totalUsed / $effectiveDays : 0;
} }
// --- Buy-cycle proxy (for products tracked without individual 'out' events) ---
// Products like salt, spices, cleaning products are never logged per-use.
// When the user buys them again it implicitly means the previous pack ran out.
// If we have ≥ 3 buy events and no (or very few) out events, we estimate
// the average cycle duration = (lastIn - firstIn) / (buyCount - 1) and
// project how many days of stock are likely left in the current cycle.
// estimatedDaysLeft = avgCycleDays daysSinceLastBuy
// This dailyRate proxy is ONLY used when the regular out-based rate is 0.
$buyCycleDays = null; // avg days per buy cycle
$buyCycleDaysLeft = null; // estimated days remaining in current cycle
if ($dailyRate == 0 && $buyCount >= 3 && $firstIn && $lastIn && $lastIn > $firstIn) {
$buyCycleDays = ($lastIn - $firstIn) / 86400 / ($buyCount - 1);
if ($buyCycleDays >= 7) { // ignore implausible < 1-week cycles
$daysSinceLastBuyFloat = ($now - $lastIn) / 86400;
$buyCycleDaysLeft = max(0, $buyCycleDays - $daysSinceLastBuyFloat);
// Derive a synthetic dailyRate so existing daysLeft / pctLeft logic works naturally
// 1 restock event ≈ consuming 1 "average package" over avgCycleDays
if ($qty > 0 && $buyCycleDays > 0) {
$dailyRate = $qty / max(1, $buyCycleDaysLeft > 0 ? $buyCycleDaysLeft : $buyCycleDays);
}
}
}
// Days of stock remaining // Days of stock remaining
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0); $daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
@@ -8853,7 +8901,9 @@ function smartShopping(PDO $db): void {
// Is this a frequently used product? (≥ 1.5 uses/month) // Is this a frequently used product? (≥ 1.5 uses/month)
$isFrequent = $usesPerMonth >= 1.5; $isFrequent = $usesPerMonth >= 1.5;
// Is it a regular product? (≥ 0.5 uses/month = at least once every 2 months) // Is it a regular product? (≥ 0.5 uses/month = at least once every 2 months)
$isRegular = $usesPerMonth >= 0.5; // Also treat buy-cycle products (≥3 buys, no out events) as regular — they are
// by definition products the user buys periodically.
$isRegular = $usesPerMonth >= 0.5 || ($buyCycleDays !== null && $buyCount >= 3);
// Is it recently relevant? (used/bought in last 60 days) // Is it recently relevant? (used/bought in last 60 days)
$isRecent = $daysSinceLastUse <= 60; $isRecent = $daysSinceLastUse <= 60;
@@ -8983,11 +9033,24 @@ function smartShopping(PDO $db): void {
$daysLeftDisplay = (int)round($daysLeft); $daysLeftDisplay = (int)round($daysLeft);
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg'; $reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg';
if ($daysLeftDisplay <= 3) { if ($daysLeftDisplay <= 3) {
// Running out within 3 days for a frequent product → high urgency
$urgency = 'high'; $urgency = 'high';
$score += 70; $score += 70;
} elseif ($daysLeftDisplay <= 7) { } elseif ($daysLeftDisplay <= 7) {
// Running out within a week → medium $urgency = 'medium';
$score += 45;
} else {
$urgency = 'low';
$score += 25;
}
}
// Buy-cycle prediction for products not tracked per-use (e.g. salt, spices):
// if daily rate was derived from buy cycles and we have < 21 days left → flag.
if ($urgency === 'none' && $buyCycleDays !== null && $dailyRate > 0
&& $daysLeft <= 21 && $isRegular && !$justRestocked) {
$daysLeftDisplay = (int)round($daysLeft);
$cycleDisplay = (int)round($buyCycleDays);
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg (ciclo medio ' . $cycleDisplay . 'gg)';
if ($daysLeftDisplay <= 7) {
$urgency = 'medium'; $urgency = 'medium';
$score += 45; $score += 45;
} else { } else {
@@ -9931,9 +9994,41 @@ function checkUpdate(): void {
]); ]);
} }
/**
* Return path to the local fingerprint deduplication cache.
* Falls back to /tmp when data/ is not writable (e.g. fresh install with wrong perms).
*/
function _getFpCachePath(): string {
$primary = __DIR__ . '/../data/reported_issue_fps.json';
return is_writable(dirname($primary)) ? $primary : (sys_get_temp_dir() . '/evershelf_fps.json');
}
/** Load & prune (> 30 days) the local FP cache. */
function _loadFpCache(): array {
$path = _getFpCachePath();
if (!file_exists($path)) return [];
$data = @json_decode(@file_get_contents($path), true) ?: [];
$cutoff = time() - 30 * 86400;
return array_filter($data, fn($v) => ($v['ts'] ?? 0) > $cutoff);
}
/** Persist the local FP cache. */
function _saveFpCache(array $cache): void {
@file_put_contents(_getFpCachePath(), json_encode($cache), LOCK_EX);
}
/** /**
* Create a GitHub issue, or add a comment to an existing open issue with the * Create a GitHub issue, or add a comment to an existing open issue with the
* same fingerprint. Uses the REST API v3 directly (no library needed). * same fingerprint. Uses the REST API v3 directly (no library needed).
*
* Deduplication strategy (two-layer):
* 1. Local file cache (data/reported_issue_fps.json or /tmp fallback) checked
* first to avoid the GitHub Search API indexing delay that caused duplicate
* issues to be created in rapid succession.
* 2. GitHub Search API used only on first occurrence (cache miss) as backup.
*
* Comment throttle: at most one recurrence comment per 30 minutes per fingerprint,
* to avoid flooding an issue when an error fires on every request.
*/ */
function _createOrCommentGithubIssue( function _createOrCommentGithubIssue(
string $token, string $repo, string $token, string $repo,
@@ -9944,13 +10039,27 @@ function _createOrCommentGithubIssue(
$fp = _errorFingerprint($source, $type, $message); $fp = _errorFingerprint($source, $type, $message);
EverLog::debug('_createOrCommentGithubIssue', ['fp' => $fp, 'type' => $type]); EverLog::debug('_createOrCommentGithubIssue', ['fp' => $fp, 'type' => $type]);
// ── 1. Search for an existing open issue with this fingerprint ───────── // ── 1. Check local cache (fast, avoids Search API indexing lag) ────────
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body"); $fpCache = _loadFpCache();
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
$existingIssueNumber = null; $existingIssueNumber = null;
if (isset($searchResult['body']['items']) && count($searchResult['body']['items']) > 0) { if (isset($fpCache[$fp])) {
$existingIssueNumber = $searchResult['body']['items'][0]['number'] ?? null; $existingIssueNumber = $fpCache[$fp]['issue'];
// Comment throttle: skip if we already commented within the last 30 min
$lastComment = $fpCache[$fp]['last_comment'] ?? 0;
if (time() - $lastComment < 1800) {
EverLog::debug('_createOrCommentGithubIssue: throttled', ['fp' => $fp]);
return;
}
} else {
// ── 2. Fall back to GitHub Search (handles first run / cache cleared) ─
$searchQuery = urlencode("repo:$repo is:issue is:open label:auto-report \"fp:$fp\" in:body");
$searchResult = _githubRequest($token, 'GET', "https://api.github.com/search/issues?q=$searchQuery&per_page=1");
if (!empty($searchResult['body']['items'][0]['number'])) {
$existingIssueNumber = (int)$searchResult['body']['items'][0]['number'];
// Populate local cache with what we found
$fpCache[$fp] = ['issue' => $existingIssueNumber, 'ts' => time(), 'last_comment' => 0];
_saveFpCache($fpCache);
}
} }
// ── Build the common details block ───────────────────────────────────── // ── Build the common details block ─────────────────────────────────────
@@ -9965,7 +10074,7 @@ function _createOrCommentGithubIssue(
$verMd = $version ? "\n**Version:** `$version`" : ''; $verMd = $version ? "\n**Version:** `$version`" : '';
if ($existingIssueNumber) { if ($existingIssueNumber) {
// ── 2a. Post a comment to the existing issue ────────────────────── // ── 3a. Post a comment to the existing issue ──────────────────────
$body = "### 🔁 Recurrence — $ts\n" $body = "### 🔁 Recurrence — $ts\n"
. "**Source:** `$source` | **Type:** `$type`\n" . "**Source:** `$source` | **Type:** `$type`\n"
. $urlMd . $uaMd . $verMd . "\n" . $urlMd . $uaMd . $verMd . "\n"
@@ -9975,8 +10084,11 @@ function _createOrCommentGithubIssue(
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments", "https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
['body' => $body] ['body' => $body]
); );
// Update throttle timestamp
$fpCache[$fp]['last_comment'] = time();
_saveFpCache($fpCache);
} else { } else {
// ── 2b. Create a new issue ──────────────────────────────────────── // ── 3b. Create a new issue ────────────────────────────────────────
// Determine labels from source // Determine labels from source
$labelMap = [ $labelMap = [
'pwa' => 'js-error', 'pwa' => 'js-error',
@@ -10004,7 +10116,7 @@ function _createOrCommentGithubIssue(
. "<!-- auto-report fp:$fp -->\n" . "<!-- auto-report fp:$fp -->\n"
. "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_"; . "_This issue was created automatically by EverShelf's error reporter. fp:`{$fp}`_";
_githubRequest($token, 'POST', $newIssueRes = _githubRequest($token, 'POST',
"https://api.github.com/repos/$repo/issues", "https://api.github.com/repos/$repo/issues",
[ [
'title' => $title, 'title' => $title,
@@ -10012,6 +10124,12 @@ function _createOrCommentGithubIssue(
'labels' => ['auto-report', $typeLabel], 'labels' => ['auto-report', $typeLabel],
] ]
); );
// Save to local cache immediately to prevent duplicates on rapid recurrences
$newNum = $newIssueRes['body']['number'] ?? null;
if ($newNum) {
$fpCache[$fp] = ['issue' => (int)$newNum, 'ts' => time(), 'last_comment' => time()];
_saveFpCache($fpCache);
}
} }
} }