Compare commits

..

5 Commits

Author SHA1 Message Date
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 116 additions and 12 deletions
+11
View File
@@ -11,6 +11,17 @@ 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.
## [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
### Added
+105 -12
View File
@@ -8813,6 +8813,29 @@ function smartShopping(PDO $db): void {
$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
$daysLeft = ($dailyRate > 0 && $qty > 0) ? $qty / $dailyRate : ($qty > 0 ? 999 : 0);
@@ -8853,7 +8876,9 @@ function smartShopping(PDO $db): void {
// Is this a frequently used product? (≥ 1.5 uses/month)
$isFrequent = $usesPerMonth >= 1.5;
// 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)
$isRecent = $daysSinceLastUse <= 60;
@@ -8983,11 +9008,24 @@ function smartShopping(PDO $db): void {
$daysLeftDisplay = (int)round($daysLeft);
$reasons[] = 'Finisce tra ~' . $daysLeftDisplay . 'gg';
if ($daysLeftDisplay <= 3) {
// Running out within 3 days for a frequent product → high urgency
$urgency = 'high';
$score += 70;
} 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';
$score += 45;
} else {
@@ -9931,9 +9969,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
* 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(
string $token, string $repo,
@@ -9944,13 +10014,27 @@ function _createOrCommentGithubIssue(
$fp = _errorFingerprint($source, $type, $message);
EverLog::debug('_createOrCommentGithubIssue', ['fp' => $fp, 'type' => $type]);
// ── 1. Search for an existing open issue with this fingerprint ─────────
$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");
// ── 1. Check local cache (fast, avoids Search API indexing lag) ────────
$fpCache = _loadFpCache();
$existingIssueNumber = null;
if (isset($searchResult['body']['items']) && count($searchResult['body']['items']) > 0) {
$existingIssueNumber = $searchResult['body']['items'][0]['number'] ?? null;
if (isset($fpCache[$fp])) {
$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 ─────────────────────────────────────
@@ -9965,7 +10049,7 @@ function _createOrCommentGithubIssue(
$verMd = $version ? "\n**Version:** `$version`" : '';
if ($existingIssueNumber) {
// ── 2a. Post a comment to the existing issue ──────────────────────
// ── 3a. Post a comment to the existing issue ──────────────────────
$body = "### 🔁 Recurrence — $ts\n"
. "**Source:** `$source` | **Type:** `$type`\n"
. $urlMd . $uaMd . $verMd . "\n"
@@ -9975,8 +10059,11 @@ function _createOrCommentGithubIssue(
"https://api.github.com/repos/$repo/issues/$existingIssueNumber/comments",
['body' => $body]
);
// Update throttle timestamp
$fpCache[$fp]['last_comment'] = time();
_saveFpCache($fpCache);
} else {
// ── 2b. Create a new issue ────────────────────────────────────────
// ── 3b. Create a new issue ────────────────────────────────────────
// Determine labels from source
$labelMap = [
'pwa' => 'js-error',
@@ -10004,7 +10091,7 @@ function _createOrCommentGithubIssue(
. "<!-- auto-report fp:$fp -->\n"
. "_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",
[
'title' => $title,
@@ -10012,6 +10099,12 @@ function _createOrCommentGithubIssue(
'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);
}
}
}