Compare commits

...

5 Commits

Author SHA1 Message Date
dadaloop82 85ccdaa6f6 Release v1.7.42: shopping totals, waste learning, stability fixes.
Document waste reason picker, stable price estimates, DB retry, and kiosk CI fixes in CHANGELOG and README.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 12:50:13 +00:00
dadaloop82 16993135b9 Merge branch 'main' of github-evershelf:dadaloop82/EverShelf 2026-06-14 12:43:44 +00:00
dadaloop82 d1716fa6ff Fix shopping estimates, waste reasons, and recurring DB/timeouts.
Price each list line as one retail purchase; learn from discard reasons to cap restock suggestions. Retry inventory_use/shopping_add on SQLITE_BUSY; extend smart_shopping time limit. Reopen feature issues #98/#125; close auto-report bugs #201–#204.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-14 12:43:03 +00:00
github-actions[bot] 3ac42f7767 chore(kiosk): publish APK v1.7.19 for LAN OTA 2026-06-11 05:48:36 +00:00
dadaloop82 eb19265586 Kiosk: auto-discover on setup, LAN OTA, English-only GitHub triage.
Auto-run LAN discovery on server step; serve kiosk updates from releases/ via kiosk_update API; check LAN before GitHub for OTA in-place upgrades. Docker CI retries hub timeouts. Remove non-English feature issue comments; triage script English-only.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 05:46:12 +00:00
21 changed files with 647 additions and 154 deletions
+15 -1
View File
@@ -77,7 +77,21 @@ jobs:
sleep 3
gh release create kiosk-latest \
--title "EverShelf Kiosk Latest" \
--notes "Alias automatico → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \
--notes "Auto alias → kiosk-${{ steps.version.outputs.name }} (versionCode ${{ steps.version.outputs.code }})" \
--prerelease \
artifacts/evershelf-kiosk.apk
- name: Publish APK to releases/ for LAN OTA
env:
GH_TOKEN: ${{ secrets.WORKFLOW_PAT || secrets.GITHUB_TOKEN }}
run: |
cp artifacts/evershelf-kiosk.apk releases/evershelf-kiosk.apk
printf '{"version":"%s","version_code":%s}\n' \
"${{ steps.version.outputs.name }}" "${{ steps.version.outputs.code }}" \
> releases/kiosk-version.json
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add releases/evershelf-kiosk.apk releases/kiosk-version.json
git diff --staged --quiet || git commit -m "chore(kiosk): publish APK v${{ steps.version.outputs.name }} for LAN OTA"
git push origin HEAD:${{ github.ref_name }}
+12 -1
View File
@@ -43,7 +43,18 @@ jobs:
- uses: actions/checkout@v6
- name: Build Docker image
run: docker build -t evershelf-test .
run: |
set -e
for attempt in 1 2 3; do
echo "Docker build attempt $attempt/3..."
if docker build -t evershelf-test .; then
exit 0
fi
echo "Attempt $attempt failed — retrying in 20s..."
sleep 20
done
echo "Docker build failed after 3 attempts"
exit 1
- name: Test container starts
run: |
+14
View File
@@ -11,6 +11,20 @@ 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.42] - 2026-06-11
### Added
- **Waste reason picker** — Discarding a product prompts for why (expired, spoiled, wrong storage, kept too long, bought too much, forgotten, bad quality, other) in IT/EN/DE/FR/ES.
- **Waste learning** — Reasons are stored per product in `app_settings.waste_learning`; caps smart-shopping suggested quantities, surfaces preferred storage location, and tightens expiry alerts after repeated spoilage.
- **`scripts/github-issue-triage.php`** — Reopens wrongly closed feature backlog items; closes resolved auto-report bugs with English comments.
### Fixed
- **Inflated shopping total** — Price each Bring!/shopping line as **one retail purchase**; convert AI €/kg prices to estimated piece weight (200 g default) instead of multiplying by piece count; cap smart-shopping conf/pz suggestions used for pricing context.
- **SQLite database locked (#201#202)** — `inventory_use` and `shopping_add` (including Bring mode) wrapped in `dbWithRetry()`.
- **Smart shopping timeout (#203#204)** — `set_time_limit(120)` on `smartShopping()` / `smartShoppingCached()` for large inventories.
- **Android kiosk CI** — Escaped apostrophes in locale `strings.xml` (de/es/fr/it); fixed Kotlin JSON string escaping in `SetupActivity.finishSetup()`.
- **GitHub triage** — `triage-open-issues.php` no longer bulk-closes enhancement/feature backlog; reopened #98 (pin products) and #125 (cooking voice commands) where not yet implemented.
## [1.7.41] - 2026-06-08
### Fixed
+12 -1
View File
@@ -25,7 +25,7 @@
[![SQLite](https://img.shields.io/badge/SQLite-3-blue.svg)](https://www.sqlite.org/)
[![Docker](https://img.shields.io/badge/Docker-Ready-2496ED.svg)](Dockerfile)
[![i18n](https://img.shields.io/badge/i18n-IT%20%7C%20EN%20%7C%20DE%20%7C%20FR%20%7C%20ES-orange.svg)](translations/)
[![Version](https://img.shields.io/badge/version-1.7.41-brightgreen.svg)](CHANGELOG.md)
[![Version](https://img.shields.io/badge/version-1.7.42-brightgreen.svg)](CHANGELOG.md)
[![GitHub stars](https://img.shields.io/github/stars/dadaloop82/EverShelf?style=social)](https://github.com/dadaloop82/EverShelf/stargazers)
[![Last commit](https://img.shields.io/github/last-commit/dadaloop82/EverShelf/main)](https://github.com/dadaloop82/EverShelf/commits/main)
[![Contributors](https://img.shields.io/github/contributors/dadaloop82/EverShelf)](https://github.com/dadaloop82/EverShelf/graphs/contributors)
@@ -40,6 +40,17 @@
---
### 🆕 Release 1.7.42 (2026-06-11)
- **Stable shopping total** — Each list item is priced as one typical purchase (no more inflated totals from 14-day restock quantities or €/kg × piece count).
- **Waste reason picker** — When discarding food, choose why (expired, wrong storage, bought too much, …); EverShelf learns and adjusts restock suggestions and storage hints.
- **Fewer SQLite lock errors** — `inventory_use` and `shopping_add` retry on `SQLITE_BUSY`; smart shopping gets a longer PHP time limit on large pantries.
- **Android kiosk** — Locale string escaping fix; setup wizard JSON save fix (CI build).
See [CHANGELOG.md](CHANGELOG.md) for full details.
---
## ✨ Features
### 🏠 NEW — Home Assistant Integration
+200 -6
View File
@@ -55,6 +55,12 @@ if (($_GET['action'] ?? '') === 'ping') {
exit;
}
// ── Kiosk OTA metadata (LAN self-host; no DB required) ───────────────────────
if (($_GET['action'] ?? '') === 'kiosk_update') {
getKioskUpdate();
exit;
}
// ── App bootstrap — same-origin browsers receive API token automatically ───────
if (($_GET['action'] ?? '') === 'app_bootstrap') {
$required = evershelfApiTokenRequired();
@@ -3121,6 +3127,121 @@ function addToInventory(PDO $db): void {
invalidateSmartShoppingCache();
}
/** Waste transaction notes use format Buttato|reason_key (legacy: plain "Buttato"). */
function _isWasteNotes(string $notes): bool {
return $notes === 'Buttato' || str_starts_with($notes, 'Buttato|');
}
function _wasteReasonKey(string $notes): ?string {
if ($notes === 'Buttato') {
return 'unknown';
}
if (preg_match('/^Buttato\|([a-z_]+)/', $notes, $m)) {
return $m[1];
}
return null;
}
function _loadWasteLearning(PDO $db): array {
static $cache = null;
if ($cache !== null) {
return $cache;
}
$row = $db->query("SELECT value FROM app_settings WHERE key = 'waste_learning'")->fetchColumn();
$cache = ($row !== false && $row !== '') ? (json_decode((string)$row, true) ?: []) : [];
return $cache;
}
function _saveWasteLearning(PDO $db, array $data): void {
$stmt = $db->prepare("INSERT INTO app_settings (key, value, updated_at) VALUES ('waste_learning', ?, datetime('now'))
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = excluded.updated_at");
$stmt->execute([json_encode($data, JSON_UNESCAPED_UNICODE)]);
invalidateSmartShoppingCache();
}
function _guessPreferredStorageLocation(string $name, string $category): string {
$n = mb_strtolower($name . ' ' . $category);
if (preg_match('/surgelat|gelato|congelat|frozen|piselli surg|spinaci surg|basilico surg/', $n)) {
return 'freezer';
}
if (preg_match('/latte|yogurt|formaggio|burro|panna|uova|insalata|rucola|spinaci|pollo|carne|pesce|prosciutto|salame|mortadella|bresaola|affettato/', $n)) {
return 'frigo';
}
return 'dispensa';
}
function _applyWasteLearning(PDO $db, int $productId, string $reason, string $location, array $product): void {
if ($reason === '' || $reason === 'other') {
return;
}
$data = _loadWasteLearning($db);
$pid = (string)$productId;
if (!isset($data[$pid])) {
$data[$pid] = [];
}
$data[$pid]['last_reason'] = $reason;
$data[$pid]['last_at'] = time();
$data[$pid]['count_' . $reason] = (int)($data[$pid]['count_' . $reason] ?? 0) + 1;
switch ($reason) {
case 'expired':
case 'spoiled':
$data[$pid]['alert_days_sooner'] = min(5, (int)($data[$pid]['alert_days_sooner'] ?? 0) + 1);
break;
case 'wrong_location':
$preferred = _guessPreferredStorageLocation($product['name'] ?? '', $product['category'] ?? '');
if ($preferred !== $location) {
$data[$pid]['preferred_location'] = $preferred;
}
break;
case 'kept_too_long':
case 'forgotten':
$data[$pid]['buy_smaller'] = true;
$data[$pid]['max_suggested_pz'] = 2;
break;
case 'bought_too_much':
$data[$pid]['buy_less'] = true;
$data[$pid]['max_suggested_conf'] = 1;
$data[$pid]['max_suggested_pz'] = 2;
break;
case 'bad_quality':
$data[$pid]['buy_less'] = true;
break;
}
_saveWasteLearning($db, $data);
}
function _maybeApplyWasteLearning(PDO $db, int $productId, string $notes, string $location): void {
if (!_isWasteNotes($notes)) {
return;
}
$reason = _wasteReasonKey($notes) ?? 'unknown';
$stmt = $db->prepare("SELECT name, category FROM products WHERE id = ?");
$stmt->execute([$productId]);
$product = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$product) {
return;
}
_applyWasteLearning($db, $productId, $reason, $location, $product);
}
function _applyWasteHintsToSuggestion(int $productId, $suggestedQty, string $suggestedUnit, array $wasteLearning): array {
$hint = $wasteLearning[(string)$productId] ?? [];
if ($suggestedQty === null || empty($hint)) {
return [$suggestedQty, $suggestedUnit];
}
if (!empty($hint['buy_less']) || !empty($hint['buy_smaller'])) {
if ($suggestedUnit === 'conf') {
$cap = (float)($hint['max_suggested_conf'] ?? 1);
$suggestedQty = min((float)$suggestedQty, max(1.0, $cap));
} elseif ($suggestedUnit === 'pz') {
$cap = (float)($hint['max_suggested_pz'] ?? 2);
$suggestedQty = min((float)$suggestedQty, max(1.0, $cap));
}
}
return [$suggestedQty, $suggestedUnit];
}
function useFromInventory(PDO $db): void {
EverLog::info('useFromInventory');
$input = json_decode(file_get_contents('php://input'), true);
@@ -3137,6 +3258,18 @@ function useFromInventory(PDO $db): void {
return;
}
try {
dbWithRetry(function () use ($db, $productId, $quantity, $useAll, $location, $notes): void {
useFromInventoryCore($db, $productId, $quantity, $useAll, $location, $notes);
});
} catch (\PDOException $e) {
EverLog::error('useFromInventory db error', ['msg' => $e->getMessage()]);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database busy — please retry']);
}
}
function useFromInventoryCore(PDO $db, $productId, $quantity, $useAll, $location, $notes): void {
// ── Server-side deduplication ─────────────────────────────────────────
// Guard against accidental double-consume triggers (scale jitter, double tap,
// delayed/offline replay burst). We only apply this stricter gate to manual
@@ -3195,10 +3328,10 @@ function useFromInventory(PDO $db): void {
$stmt->execute([$productId]);
$allItems = $stmt->fetchAll();
$totalRemoved = 0;
$explicitFinish = ($notes !== 'Buttato');
$explicitFinish = !_isWasteNotes($notes);
foreach ($allItems as $item) {
$totalRemoved += $item['quantity'];
$type = ($notes === 'Buttato') ? 'waste' : 'out';
$type = _isWasteNotes($notes) ? 'waste' : 'out';
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$productId, $type, $item['quantity'], $item['location'], $notes]);
@@ -3212,6 +3345,7 @@ function useFromInventory(PDO $db): void {
$stmt->execute([$item['id']]);
}
}
_maybeApplyWasteLearning($db, (int)$productId, $notes, $location === '__all__' ? 'dispensa' : $location);
echo json_encode(['success' => true, 'remaining' => 0, 'removed' => $totalRemoved]);
return;
}
@@ -3270,9 +3404,10 @@ function useFromInventory(PDO $db): void {
}
// Log transaction
$type = ($notes === 'Buttato') ? 'waste' : 'out';
$type = _isWasteNotes($notes) ? 'waste' : 'out';
$stmt3 = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
$stmt3->execute([$productId, $type, $quantity, $location, $notes]);
_maybeApplyWasteLearning($db, (int)$productId, $notes, $location);
$remaining = $newFraction > 0.001 ? $newFraction : 0;
// Skip the normal flow — jump to Bring! check and response
@@ -3361,13 +3496,14 @@ function useFromInventory(PDO $db): void {
}
// Log transaction (actual amount removed, not requested)
$type = ($notes === 'Buttato') ? 'waste' : 'out';
$type = _isWasteNotes($notes) ? 'waste' : 'out';
$stmt = $db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, ?, ?, ?, ?)");
$stmt->execute([$productId, $type, $actualDeducted, $location, $notes]);
_maybeApplyWasteLearning($db, (int)$productId, $notes, $location);
// User explicitly chose "use all/finished": remove this row now instead of
// leaving quantity=0 pending confirmation.
if ($useAll && $notes !== 'Buttato' && $newQty <= 0) {
if ($useAll && !_isWasteNotes($notes) && $newQty <= 0) {
$stmt = $db->prepare("DELETE FROM inventory WHERE id = ?");
$stmt->execute([$existing['id']]);
}
@@ -4923,6 +5059,34 @@ function getConsumptionPredictions(PDO $db): void {
// ===== SETTINGS =====
function getKioskUpdate(): void {
$root = dirname(__DIR__);
$jsonPath = $root . '/releases/kiosk-version.json';
$apkPath = $root . '/releases/evershelf-kiosk.apk';
if (!is_file($jsonPath) || !is_file($apkPath)) {
echo json_encode(['success' => false, 'error' => 'not_available']);
return;
}
$meta = json_decode((string)file_get_contents($jsonPath), true);
if (!is_array($meta)) {
echo json_encode(['success' => false, 'error' => 'invalid_metadata']);
return;
}
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|| strtolower((string)($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '')) === 'https'
? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$script = $_SERVER['SCRIPT_NAME'] ?? '/api/index.php';
$basePath = preg_replace('#/api/index\.php$#', '', $script) ?: '';
$defaultApkUrl = $scheme . '://' . $host . $basePath . '/releases/evershelf-kiosk.apk';
echo json_encode([
'success' => true,
'version' => (string)($meta['version'] ?? ''),
'version_code' => (int)($meta['version_code'] ?? 0),
'apk_url' => (string)($meta['apk_url'] ?? $defaultApkUrl),
], JSON_UNESCAPED_UNICODE);
}
function getServerSettings(): void {
EverLog::debug('getServerSettings');
$geminiKey = env('GEMINI_API_KEY');
@@ -10578,6 +10742,7 @@ function invalidateSmartShoppingCache(): void {
function smartShoppingCached(PDO $db): void {
EverLog::info('smartShoppingCached');
set_time_limit(120);
// Never let the browser or proxy cache this — urgency is time-sensitive
header('Cache-Control: no-cache, no-store, must-revalidate');
header('Pragma: no-cache');
@@ -10656,6 +10821,7 @@ function _productOnBring(string $productName, array $bringItems, string $shoppin
function smartShopping(PDO $db): void {
EverLog::info('smartShopping');
set_time_limit(120);
$now = time();
$today = date('Y-m-d');
@@ -10767,6 +10933,7 @@ function smartShopping(PDO $db): void {
// 5. Analyze each product
$items = [];
$wasteLearning = _loadWasteLearning($db);
foreach ($products as $p) {
$pid = $p['id'];
$inv = $inventory[$pid] ?? null;
@@ -11329,6 +11496,13 @@ function smartShopping(PDO $db): void {
}
}
[$suggestedQty, $suggestedUnit] = _applyWasteHintsToSuggestion($pid, $suggestedQty, $suggestedUnit ?? $unit, $wasteLearning);
$wHint = $wasteLearning[(string)$pid] ?? [];
if (!empty($wHint['preferred_location'])) {
$locLabel = $wHint['preferred_location'];
$reasons[] = "Past waste: store in {$locLabel}";
}
$items[] = [
'product_id' => $pid,
'name' => $p['name'],
@@ -11610,10 +11784,30 @@ function shoppingGetList(PDO $db): void {
function shoppingAdd(PDO $db): void {
if (isShoppingBringMode()) {
bringAddItems($db);
try {
dbWithRetry(function () use ($db): void {
bringAddItems($db);
});
} catch (\PDOException $e) {
EverLog::error('shoppingAdd/bring db error', ['msg' => $e->getMessage()]);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database busy — please retry']);
}
return;
}
$input = json_decode(file_get_contents('php://input'), true) ?? [];
try {
dbWithRetry(function () use ($db, $input): void {
shoppingAddInternal($db, $input);
});
} catch (\PDOException $e) {
EverLog::error('shoppingAdd db error', ['msg' => $e->getMessage()]);
http_response_code(500);
echo json_encode(['success' => false, 'error' => 'Database busy — please retry']);
}
}
function shoppingAddInternal(PDO $db, array $input): void {
$items = $input['items'] ?? [];
$added = 0; $updated = 0; $skipped = 0;
foreach ($items as $item) {
+55 -24
View File
@@ -6809,6 +6809,53 @@ async function quickUse(productId, location) {
}
}
const WASTE_REASON_KEYS = ['expired', 'spoiled', 'wrong_location', 'kept_too_long', 'bought_too_much', 'forgotten', 'bad_quality', 'other'];
function _wasteNotesForReason(reason) {
return 'Buttato|' + reason;
}
function _showWasteReasonModal(productLabel, onPick) {
const buttons = WASTE_REASON_KEYS.map(r =>
`<button type="button" class="btn btn-large full-width" style="margin-bottom:8px;text-align:left" data-waste-reason="${r}">${escapeHtml(t('waste.reason_' + r))}</button>`
).join('');
document.getElementById('modal-content').innerHTML = `
<div class="modal-header">
<h3>${t('waste.reason_title')}</h3>
<button class="modal-close" onclick="closeModal()"></button>
</div>
<p style="color:var(--text-muted);margin:8px 0 16px">${escapeHtml(productLabel)}</p>
<p style="color:var(--text-muted);margin:0 0 12px;font-size:0.9rem">${t('waste.reason_subtitle')}</p>
<div style="display:flex;flex-direction:column;gap:0">${buttons}
<button type="button" class="btn btn-secondary full-width" style="margin-top:8px" onclick="closeModal()">${t('confirm.cancel')}</button>
</div>
`;
document.getElementById('modal-overlay').style.display = 'flex';
document.querySelectorAll('[data-waste-reason]').forEach(btn => {
btn.addEventListener('click', () => {
const reason = btn.getAttribute('data-waste-reason');
closeModal();
onPick(reason);
});
});
}
function _inventoryWaste(payload, productLabel) {
return new Promise((resolve, reject) => {
_showWasteReasonModal(productLabel || '', async (reason) => {
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', { ...payload, notes: _wasteNotesForReason(reason) });
resolve(result);
} catch (e) {
reject(e);
} finally {
showLoading(false);
}
});
});
}
async function deleteInventoryItem(id) {
const item = currentInventory.find(i => i.id === id);
const unit = item ? (item.unit || 'pz') : 'pz';
@@ -6853,19 +6900,15 @@ async function _discardOnePiece(inventoryId) {
const item = currentInventory.find(i => i.id === inventoryId);
if (!item) { closeModal(); return; }
closeModal();
showLoading(true);
try {
await api('inventory_use', {}, 'POST', {
await _inventoryWaste({
product_id: item.product_id,
quantity: 1,
location: item.location,
notes: 'Buttato'
});
showLoading(false);
}, item.name);
showToast(t('toast.thrown_away_partial', { qty: 1, unit: item.unit || 'pz', name: item.name }), 'success');
refreshCurrentPage();
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
@@ -6874,19 +6917,15 @@ async function _discardAllFromModal(inventoryId) {
const item = currentInventory.find(i => i.id === inventoryId);
if (!item) { closeModal(); return; }
closeModal();
showLoading(true);
try {
await api('inventory_use', {}, 'POST', {
await _inventoryWaste({
product_id: item.product_id,
use_all: true,
location: item.location,
notes: 'Buttato'
});
showLoading(false);
}, item.name);
showToast(t('toast.thrown_away', { name: item.name }), 'success');
refreshCurrentPage();
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
@@ -9180,15 +9219,12 @@ async function throwAll() {
t('use.throw_all_confirm_title') || '🗑️ Butta tutto',
(t('use.throw_all_confirm_msg') || 'Vuoi davvero buttare via tutto il prodotto?') + (name ? `\n"${name}"` : ''),
async () => {
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
const result = await _inventoryWaste({
product_id: currentProduct.id,
use_all: true,
location: '__all__',
notes: 'Buttato'
});
showLoading(false);
}, name);
if (result.success) {
showToast(t('toast.thrown_away', { name: currentProduct.name }), 'success');
showPage('dashboard');
@@ -9196,7 +9232,6 @@ async function throwAll() {
showToast(result.error || t('error.generic'), 'error');
}
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
},
@@ -9208,15 +9243,12 @@ async function throwPartial() {
const qty = parseFloat(document.getElementById('throw-quantity').value) || 1;
const loc = document.getElementById('throw-location').value;
closeModal();
showLoading(true);
try {
const result = await api('inventory_use', {}, 'POST', {
const result = await _inventoryWaste({
product_id: currentProduct.id,
quantity: qty,
location: loc,
notes: 'Buttato'
});
showLoading(false);
}, currentProduct.name);
if (result.success) {
showToast(t('toast.thrown_away_partial', { qty, unit: currentProduct.unit || 'pz', name: currentProduct.name }), 'success');
showPage('dashboard');
@@ -9224,7 +9256,6 @@ async function throwPartial() {
showToast(result.error || t('error.generic'), 'error');
}
} catch(e) {
showLoading(false);
showToast(t('error.connection'), 'error');
}
}
+2 -2
View File
@@ -11,8 +11,8 @@ android {
applicationId = "it.dadaloop.evershelf.kiosk"
minSdk = 24
targetSdk = 35
versionCode = 19
versionName = "1.7.18"
versionCode = 20
versionName = "1.7.19"
}
signingConfigs {
@@ -643,6 +643,79 @@ class KioskActivity : AppCompatActivity() {
webView.evaluateJavascript("$jsCallback($escaped)", null)
}
}
val currentKiosk = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" }
val installedVc: Long = try {
val pi = packageManager.getPackageInfo(packageName, 0)
if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
else @Suppress("DEPRECATION") pi.versionCode.toLong()
} catch (_: Exception) { -1L }
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
for (i in 0 until maxOf(r.size, l.size)) {
val rv = r.getOrElse(i) { 0 }
val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
fun needsUpdate(remoteVersion: String, remoteVc: Long): Boolean = when {
remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc
currentKiosk.isNotEmpty() && remoteVersion.matches(Regex("\\d+\\.\\d+.*")) ->
semverNewer(remoteVersion, currentKiosk)
else -> false
}
fun applyUpdate(remoteVersion: String, apkUrl: String) {
val result = JSONObject()
.put("has_update", true)
.put("current", currentKiosk)
.put("latest", remoteVersion)
.put("apk_url", apkUrl)
notifyJs(result)
prefs.edit()
.putString(KEY_PENDING_UPDATE_VERSION, remoteVersion)
.putString(KEY_PENDING_UPDATE_URL, apkUrl)
.apply()
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk$remoteVersion", apkUrl) }
}
// 1) Prefer LAN/self-hosted update (no GitHub required)
val baseUrl = (prefs.getString(KEY_URL, "") ?: "").trim().trimEnd('/')
if (baseUrl.isNotEmpty()) {
try {
val localApi = "$baseUrl/api/index.php?action=kiosk_update"
val conn = openTrustedConnection(localApi)
conn.connectTimeout = 5000
conn.readTimeout = 5000
if (conn.responseCode == 200) {
val localJson = JSONObject(conn.inputStream.bufferedReader().readText())
conn.disconnect()
if (localJson.optBoolean("success")) {
val remoteVersion = localJson.optString("version", "")
val remoteVc = localJson.optLong("version_code", -1L)
val apkUrl = localJson.optString("apk_url", "")
if (apkUrl.isNotEmpty() && needsUpdate(remoteVersion, remoteVc)) {
applyUpdate(remoteVersion, apkUrl)
return@Thread
}
if (!needsUpdate(remoteVersion, remoteVc)) {
notifyJs(JSONObject().put("has_update", false).put("source", "local"))
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
return@Thread
}
}
} else conn.disconnect()
} catch (_: Exception) { /* fall through to GitHub */ }
}
// 2) GitHub release fallback (requires internet)
try {
val conn = URL(GITHUB_RELEASES_API).openConnection() as java.net.HttpURLConnection
conn.setRequestProperty("Accept", "application/vnd.github+json")
@@ -657,51 +730,16 @@ class KioskActivity : AppCompatActivity() {
val body = conn.inputStream.bufferedReader().readText()
conn.disconnect()
val json = JSONObject(body)
val latestTag = json.optString("tag_name", "")
if (latestTag.isEmpty()) {
notifyJs(JSONObject().put("has_update", false).put("error", "no tag"))
return@Thread
}
val currentKiosk = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: ""
} catch (_: Exception) { "" }
val installedVc: Long = try {
val pi = packageManager.getPackageInfo(packageName, 0)
if (Build.VERSION.SDK_INT >= 28) pi.longVersionCode
else @Suppress("DEPRECATION") pi.versionCode.toLong()
} catch (_: Exception) { -1L }
// The kiosk-latest release uses a non-semver tag ("kiosk-latest").
// Extract the actual kiosk version from the release body text.
// Body format: "Alias automatico → kiosk-X.Y.Z (versionCode N)".
val bodyText = json.optString("body", "")
val norm = { v: String -> v.replace(Regex("^[^0-9]*"), "") }
val remoteKioskVersion = Regex("""kiosk-v?(\d+\.\d+(?:\.\d+)?)""")
.find(bodyText)?.groupValues?.get(1)
?.takeIf { it.isNotEmpty() }
?: norm(latestTag)
?: norm(json.optString("tag_name", ""))
val remoteVc = Regex("""versionCode[=:\s(]+(\d+)""", RegexOption.IGNORE_CASE)
.find(bodyText)?.groupValues?.get(1)?.toLongOrNull() ?: -1L
// Compare semver: returns true if `remote` is strictly greater than `local`
fun semverNewer(remote: String, local: String): Boolean {
val r = remote.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val l = local.split(".").map { it.filter(Char::isDigit).toIntOrNull() ?: 0 }
val len = maxOf(r.size, l.size)
for (i in 0 until len) {
val rv = r.getOrElse(i) { 0 }
val lv = l.getOrElse(i) { 0 }
if (rv != lv) return rv > lv
}
return false
}
val isSemver = remoteKioskVersion.matches(Regex("\\d+\\.\\d+.*"))
// Get APK URL from assets; fall back to the hardcoded KIOSK_DOWNLOAD_URL
val assets = json.optJSONArray("assets")
var kioskApkUrl = ""
if (assets != null) {
@@ -715,39 +753,35 @@ class KioskActivity : AppCompatActivity() {
}
if (kioskApkUrl.isEmpty()) kioskApkUrl = KIOSK_DOWNLOAD_URL
val kioskNeedsUpdate = when {
remoteVc > 0 && installedVc >= 0 -> remoteVc > installedVc
currentKiosk.isNotEmpty() && isSemver -> semverNewer(remoteKioskVersion, currentKiosk)
else -> false
}
val result = JSONObject()
.put("has_update", kioskNeedsUpdate)
.put("current", currentKiosk)
.put("latest", remoteKioskVersion)
.put("apk_url", kioskApkUrl)
notifyJs(result)
if (!kioskNeedsUpdate) {
// Clear any stale pending update if the current version is now up to date
if (!needsUpdate(remoteKioskVersion, remoteVc)) {
notifyJs(JSONObject().put("has_update", false))
prefs.edit().remove(KEY_PENDING_UPDATE_VERSION).remove(KEY_PENDING_UPDATE_URL).apply()
return@Thread
}
// Persist the pending update so the banner reappears after a crash/restart
prefs.edit()
.putString(KEY_PENDING_UPDATE_VERSION, remoteKioskVersion)
.putString(KEY_PENDING_UPDATE_URL, kioskApkUrl)
.apply()
runOnUiThread { showNativeUpdateBanner("🔄 Kiosk $currentKiosk$remoteKioskVersion", kioskApkUrl) }
applyUpdate(remoteKioskVersion, kioskApkUrl)
} catch (e: Exception) {
notifyJs(JSONObject().put("has_update", false).put("error", e.message ?: "network error"))
}
}.start()
}
/** HTTPS with self-signed cert support (LAN servers). */
private fun openTrustedConnection(urlStr: String): java.net.HttpURLConnection {
val conn = URL(urlStr).openConnection()
if (conn is javax.net.ssl.HttpsURLConnection) {
val trustAll = arrayOf<javax.net.ssl.TrustManager>(object : javax.net.ssl.X509TrustManager {
override fun checkClientTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
override fun checkServerTrusted(c: Array<java.security.cert.X509Certificate>?, t: String?) {}
override fun getAcceptedIssuers(): Array<java.security.cert.X509Certificate> = arrayOf()
})
val sc = javax.net.ssl.SSLContext.getInstance("TLS")
sc.init(null, trustAll, java.security.SecureRandom())
conn.sslSocketFactory = sc.socketFactory
conn.hostnameVerifier = javax.net.ssl.HostnameVerifier { _, _ -> true }
}
return conn as java.net.HttpURLConnection
}
/**
* On resume: if a previous session detected an available update and saved it to prefs,
* restore the update banner immediately without a network round-trip.
@@ -540,6 +540,11 @@ class SetupActivity : AppCompatActivity() {
// Cancel auto-discover when leaving server step
if (step != 3) discoverCancelled.set(true)
// Auto-discover when entering server step (empty URL only)
if (step == 3 && urlEdit.text.toString().trim().isEmpty()) {
autoDiscover()
}
// Scroll to top
try { findViewById<ScrollView>(R.id.setupScrollView).scrollTo(0, 0) } catch (_: Exception) {}
}
+2 -2
View File
@@ -94,7 +94,7 @@
<div id="preloader-warnings" class="preloader-warnings" style="display:none"></div>
<div id="preloader-error-msg" class="preloader-error-msg" style="display:none"></div>
<button id="preloader-retry-btn" class="preloader-retry-btn" style="display:none" onclick="_startupRetry()">🔄 <span data-i18n="startup.retry">Riprova</span></button>
<span class="app-preloader-version" id="preloader-version">v1.7.41</span>
<span class="app-preloader-version" id="preloader-version">v1.7.42</span>
</div>
</div>
@@ -107,7 +107,7 @@
<!-- Title — left-aligned; grows to fill space -->
<div class="header-title-wrap">
<h1 class="header-title" onclick="showPage('dashboard')">
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.41</span>
<img src="assets/img/logo/logo_icon.png" alt="" class="header-logo-icon" aria-hidden="true" /><span data-i18n="nav.title">EverShelf</span><span class="header-version">v1.7.42</span>
</h1>
<!-- Update badge — shown alongside title, never replaces it -->
<span class="header-update-badge" id="header-update-badge" style="display:none"></span>
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "EverShelf",
"short_name": "EverShelf",
"description": "Gestione completa della dispensa di casa con scansione barcode",
"version": "1.7.41",
"version": "1.7.42",
"start_url": "/evershelf/",
"display": "standalone",
"background_color": "#f0f4e8",
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
{"version":"1.7.19","version_code":20}
+79
View File
@@ -0,0 +1,79 @@
#!/usr/bin/env php
<?php
/** Delete all comments on open feature/enhancement backlog issues (English-only tracker policy). */
declare(strict_types=1);
define('CRON_MODE', true);
require_once __DIR__ . '/../api/bootstrap.php';
require_once __DIR__ . '/../api/lib/github.php';
require_once __DIR__ . '/../api/lib/constants.php';
$token = _ghToken();
if ($token === '') {
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
exit(1);
}
function ghRequest(string $token, string $method, string $url, ?array $body = null): array {
$ch = curl_init($url);
$headers = [
'Authorization: token ' . $token,
'Accept: application/vnd.github+json',
'X-GitHub-Api-Version: 2022-11-28',
'User-Agent: EverShelf-Triage/1.0',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 30,
]);
if ($method === 'DELETE') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'DELETE');
} elseif ($method === 'GET') {
// default
}
if ($body !== null) {
$headers[] = 'Content-Type: application/json';
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$raw = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['code' => $code, 'body' => $raw];
}
$issues = [122, 121, 120, 119, 118, 117, 116, 115, 114, 106, 105, 104, 103, 102, 101, 97, 93, 81, 80, 79, 69, 67, 65];
$deleted = 0;
foreach ($issues as $num) {
$page = 1;
while (true) {
$url = 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments?per_page=100&page=$page";
$r = ghRequest($token, 'GET', $url);
if ($r['code'] !== 200) {
fwrite(STDERR, "#$num list comments HTTP {$r['code']}\n");
break;
}
$comments = json_decode($r['body'], true);
if (!is_array($comments) || empty($comments)) {
break;
}
foreach ($comments as $c) {
$id = (int)($c['id'] ?? 0);
if ($id <= 0) continue;
$dr = ghRequest($token, 'DELETE', 'https://api.github.com/repos/' . GH_REPO . "/issues/comments/$id");
if ($dr['code'] === 204) {
$deleted++;
echo "deleted comment $id on #$num\n";
} else {
fwrite(STDERR, "FAIL delete comment $id on #$num HTTP {$dr['code']}\n");
}
usleep(200000);
}
if (count($comments) < 100) break;
$page++;
}
}
echo "Done. Deleted $deleted comments.\n";
+81
View File
@@ -0,0 +1,81 @@
#!/usr/bin/env php
<?php
/** Reopen wrongly closed feature issues; close resolved auto-report bugs (English). */
declare(strict_types=1);
define('CRON_MODE', true);
require_once __DIR__ . '/../api/bootstrap.php';
require_once __DIR__ . '/../api/lib/github.php';
require_once __DIR__ . '/../api/lib/constants.php';
$token = _ghToken();
if ($token === '') {
fwrite(STDERR, "ERROR: GH_ISSUE_TOKEN not configured\n");
exit(1);
}
function ghApi(string $token, string $method, string $url, array $payload = []): array {
$ch = curl_init($url);
$headers = [
'Authorization: token ' . $token,
'Accept: application/vnd.github+json',
'X-GitHub-Api-Version: 2022-11-28',
'User-Agent: EverShelf-Triage/1.0',
'Content-Type: application/json',
];
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_TIMEOUT => 20,
]);
if ($method === 'PATCH') {
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PATCH');
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
} elseif ($method === 'POST') {
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$raw = curl_exec($ch);
$code = (int)curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
return ['http_code' => $code, 'body' => json_decode($raw ?: '{}', true) ?: []];
}
function comment(string $token, int $num, string $body): void {
$r = ghApi($token, 'POST', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num/comments", ['body' => $body]);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK comment #$num\n" : "FAIL comment #$num\n";
}
function closeIssue(string $token, int $num): void {
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'closed']);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK close #$num\n" : "FAIL close #$num\n";
}
function reopenIssue(string $token, int $num): void {
$r = ghApi($token, 'PATCH', 'https://api.github.com/repos/' . GH_REPO . "/issues/$num", ['state' => 'open']);
echo $r['http_code'] >= 200 && $r['http_code'] < 300 ? "OK reopen #$num\n" : "FAIL reopen #$num\n";
}
$reopen = [
125 => "Reopened: **voice commands in cooking mode** are not implemented yet (only TTS readout exists). This was closed by mistake during bulk triage — the feature backlog should stay open until hands-free step navigation ships.",
98 => "Reopened: **pin favourite products to the top of inventory** is not implemented yet (recipe favourites #124 are done, but product pinning is a separate request). Closed by mistake — keeping on the backlog.",
];
foreach ($reopen as $num => $msg) {
comment($token, $num, $msg);
reopenIssue($token, $num);
}
$bugs = [
201 => 'Fixed in latest develop: `inventory_use` and `shopping_add` now retry on `SQLITE_BUSY` via `dbWithRetry()` (same pattern as #198).',
202 => 'Fixed: Bring/internal `shopping_add` wrapped in `dbWithRetry()` to survive cron + PWA concurrent writes.',
203 => 'Fixed: `smartShopping()` / `smartShoppingCached()` now call `set_time_limit(120)` so large pantries no longer hit the 30s PHP fatal.',
204 => 'Fixed: same as #203 — smart shopping timeout caused HTTP 500; extended execution limit resolves the crash.',
];
foreach ($bugs as $num => $msg) {
comment($token, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._");
closeIssue($token, $num);
}
echo "Done.\n";
+14 -56
View File
@@ -1,7 +1,8 @@
#!/usr/bin/env php
<?php
/**
* One-shot triage: comment + close resolved auto-report bugs; reply on #200 (keep open).
* Triage resolved auto-report bugs only (English comments).
* Feature/enhancement backlog issues are never bulk-closed here.
* Usage: php scripts/triage-open-issues.php [--dry-run]
*/
declare(strict_types=1);
@@ -75,66 +76,23 @@ function closeIssue(string $token, string $repo, int $num, bool $dryRun): bool {
return false;
}
// ── #200: reply only, keep OPEN ─────────────────────────────────────────────
$body200 = <<<'MD'
Ciao Marco, grazie per la segnalazione dettagliata.
Il messaggio **«Impossibile contattare il server»** compare quando il browser **non riesce a completare** la richiesta a `api/index.php?action=health_check`. Quindi phpinfo funziona, ma **l'endpoint API no** (404, redirect, TLS, path sbagliato, ecc.).
### Check rapidi (dalla macchina dove apri il browser)
```bash
curl -sv "https://TUO-DOMINIO/api/index.php?action=ping"
curl -sv "https://TUO-DOMINIO/api/index.php?action=health_check"
```
Se uno dei due fallisce: DevTools → **Network** → URL esatto e **status code** della richiesta `health_check`.
### Cause frequenti con Traefik + Docker Swarm
1. **Routing incompleto** — Traefik deve inoltrare `/` **e** `/api/*`, non solo la homepage.
2. **Redirect HTTPS** — dietro Traefik serve `X-Forwarded-Proto: https`, oppure disabilitare il redirect in `.htaccess`. Nelle immagini recenti il Dockerfile imposta `SetEnvIf X-Forwarded-Proto "https" HTTPS=on`.
3. **Sottopath** — EverShelf usa URL relativi (`api/index.php`); se l'app è su `/sottocartella/`, l'URL pubblico deve essere coerente.
4. **Volume `data/`** — al primo avvio può essere quasi vuoto; assicurati permessi scrivibili:
```bash
docker exec -it CONTAINER chown -R www-data:www-data /var/www/html/data
docker exec -it CONTAINER chmod -R 775 /var/www/html/data
```
5. **`API_TOKEN` in `.env`** — se impostato, compare un prompt token (non «server non raggiungibile»).
### Per il passo successivo
Puoi condividere:
- URL pubblico esatto (con path)
- Output dei due `curl` sopra
- Screenshot Network tab su `health_check`
- Labels Traefik del servizio (router + middlewares)
Resta aperta finché non confermi che `ping`/`health_check` rispondono — poi chiudiamo insieme.
MD;
commentIssue($token, $repo, 200, $body200, $dryRun);
// ── Resolved auto-report bugs ───────────────────────────────────────────────
$bugs = [
198 => "Risolto in develop: `PRAGMA busy_timeout` portato a 10s e `dbWithRetry()` su `updateInventory` per ritentare su SQLITE_BUSY quando cron smart-shopping e PWA scrivono in parallelo.",
199 => "Duplicato di #198 — stesso evento (`inventory_update` → database locked). Fix: retry + busy_timeout aumentato.",
196 => "Risolto in v1.7.38+: `saveProduct` intercetta `UNIQUE constraint failed: products.barcode`, fa merge sul prodotto esistente o risponde 409 JSON (`barcode_already_used`) invece di HTTP 500.",
197 => "Conseguenza lato PWA del crash PHP #196 — risolto con gestione barcode duplicato in `saveProduct`.",
195 => "Risolto: `EverLog::request()` ora riceve sempre stringhe — `\$method = (string)(\$_SERVER['REQUEST_METHOD'] ?? 'GET')` (fix CLI/cron che passavano null).",
193 => "Stesso root cause di #195 (fatal TypeError su `EverLog::request` con method null da CLI). Fix già in develop.",
194 => "Risolto: `_applySpesaScanUI` usava `currentPage` (inesistente) → corretto in `_currentPageId`.",
192 => "Risolto: in `renderShoppingItems` la variabile `enriched` veniva referenziata prima della dichiarazione (TDZ). Ora `enrichedRaw` → `_dedupeShoppingByGeneric` → `enriched`.",
191 => "Risolto: in `_runStartupCheck` `setProgress` è dichiarata prima delle chiamate e `barEl` inizializzato prima dell'uso (niente più TDZ).",
134 => "Segnalazione auto-report su volume Docker non scrivibile. Mitigazioni: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile `chown www-data`. Su Swarm: `chown -R www-data:www-data data` al primo boot.",
184 => "Correlato a #134: SQLite readonly quando `data/` o `evershelf.db` non sono scrivibili. Fix operativo + chmod WAL/SHM sidecar in `_ensureDbWritable()`.",
198 => 'Fixed in develop: `PRAGMA busy_timeout` raised to 10s and `dbWithRetry()` on `updateInventory` retries SQLITE_BUSY when cron and PWA write in parallel.',
199 => 'Duplicate of #198 — same event (`inventory_update` → database locked). Fix: retry + longer busy_timeout.',
196 => 'Fixed in v1.7.38+: `saveProduct` handles duplicate barcodes (merge or 409 JSON) instead of HTTP 500.',
197 => 'PWA side-effect of PHP crash #196 — fixed with duplicate barcode handling in `saveProduct`.',
195 => 'Fixed: `EverLog::request()` always receives strings — `(string)($_SERVER[\'REQUEST_METHOD\'] ?? \'GET\')`.',
193 => 'Same root cause as #195 (TypeError when method was null from CLI).',
194 => 'Fixed: `_applySpesaScanUI` referenced `currentPage` → corrected to `_currentPageId`.',
192 => 'Fixed: TDZ on `enriched` in `renderShoppingItems`.',
191 => 'Fixed: TDZ on `setProgress` / `barEl` in `_runStartupCheck`.',
134 => 'Auto-report for non-writable Docker volume. Mitigations: `_ensureDataDir()`, `_ensureDbWritable()`, Dockerfile chown.',
184 => 'Related to #134: SQLite readonly when `data/` is not writable.',
];
foreach ($bugs as $num => $msg) {
commentIssue($token, $repo, $num, $msg . "\n\n_Chiuso dopo triage — fix in develop._", $dryRun);
commentIssue($token, $repo, $num, $msg . "\n\n_Closed after triage — fix shipped in develop._", $dryRun);
closeIssue($token, $repo, $num, $dryRun);
}
// Feature/enhancement issues stay OPEN — do not bulk-close backlog items here.
echo "Done.\n";
+12
View File
@@ -353,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Ja, entsorgen",
"locations_short": "Orte"
},
"waste": {
"reason_title": "Warum wirfst du es weg?",
"reason_subtitle": "Das hilft uns, ähnliche Verschwendung zu vermeiden.",
"reason_expired": "⏰ Abgelaufen",
"reason_spoiled": "🦠 Verdorben",
"reason_wrong_location": "📍 Falscher Lagerort",
"reason_kept_too_long": "⏳ Zu lange aufbewahrt",
"reason_bought_too_much": "🛒 Zu viel gekauft",
"reason_forgotten": "😴 Vergessen / nicht rechtzeitig genutzt",
"reason_bad_quality": "👎 Schlechte Qualität beim Kauf",
"reason_other": "❓ Sonstiges"
},
"product": {
"title_new": "Neues Produkt",
"title_edit": "Produkt bearbeiten",
+12
View File
@@ -353,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Yes, discard",
"locations_short": "places"
},
"waste": {
"reason_title": "Why are you discarding it?",
"reason_subtitle": "This helps us prevent similar waste next time.",
"reason_expired": "⏰ Expired",
"reason_spoiled": "🦠 Spoiled / gone bad",
"reason_wrong_location": "📍 Wrong storage (fridge/freezer/pantry)",
"reason_kept_too_long": "⏳ Kept too long",
"reason_bought_too_much": "🛒 Bought too much",
"reason_forgotten": "😴 Forgotten / not used in time",
"reason_bad_quality": "👎 Poor quality when bought",
"reason_other": "❓ Other"
},
"product": {
"title_new": "New Product",
"title_edit": "Edit Product",
+12
View File
@@ -353,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Sí, desechar",
"locations_short": "ubicaciones"
},
"waste": {
"reason_title": "¿Por qué lo tiras?",
"reason_subtitle": "Nos ayuda a evitar desperdicios similares.",
"reason_expired": "⏰ Caducado",
"reason_spoiled": "🦠 Estropeado",
"reason_wrong_location": "📍 Lugar de guardado incorrecto",
"reason_kept_too_long": "⏳ Guardado demasiado tiempo",
"reason_bought_too_much": "🛒 Comprado de más",
"reason_forgotten": "😴 Olvidado / no usado a tiempo",
"reason_bad_quality": "👎 Mala calidad al comprar",
"reason_other": "❓ Otro"
},
"product": {
"title_new": "Nuevo producto",
"title_edit": "Editar producto",
+12
View File
@@ -353,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Oui, jeter",
"locations_short": "emplacements"
},
"waste": {
"reason_title": "Pourquoi le jetez-vous ?",
"reason_subtitle": "Cela nous aide à éviter des gaspillages similaires.",
"reason_expired": "⏰ Périmé",
"reason_spoiled": "🦠 Abîmé / gâté",
"reason_wrong_location": "📍 Mauvais emplacement",
"reason_kept_too_long": "⏳ Conservé trop longtemps",
"reason_bought_too_much": "🛒 Acheté en trop grande quantité",
"reason_forgotten": "😴 Oublié / pas utilisé à temps",
"reason_bad_quality": "👎 Mauvaise qualité à l'achat",
"reason_other": "❓ Autre"
},
"product": {
"title_new": "Nouveau produit",
"title_edit": "Modifier le produit",
+12
View File
@@ -353,6 +353,18 @@
"throw_all_confirm_btn": "🗑️ Sì, butta",
"locations_short": "posti"
},
"waste": {
"reason_title": "Perché lo butti?",
"reason_subtitle": "Ci aiuta a evitare sprechi simili in futuro.",
"reason_expired": "⏰ Scaduto",
"reason_spoiled": "🦠 Andato a male / deperito",
"reason_wrong_location": "📍 Posto sbagliato (frigo/freezer/dispensa)",
"reason_kept_too_long": "⏳ Tenuto troppo a lungo",
"reason_bought_too_much": "🛒 Comprato troppo",
"reason_forgotten": "😴 Dimenticato / non usato in tempo",
"reason_bad_quality": "👎 Qualità scadente all'acquisto",
"reason_other": "❓ Altro"
},
"product": {
"title_new": "Nuovo Prodotto",
"title_edit": "Modifica Prodotto",