Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c7f3c95d75 | |||
| a6f90a07e5 | |||
| 2d07001c5b | |||
| faa55eda93 | |||
| 0b902d7c19 |
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user