fix: kiosk overlay, preferred use-location, scale reconnect, Bring! translation, smart cache invalidation
- Kiosk: replace header-inject overlay with position:fixed div appended to <html> so buttons appear regardless of SPA init timing - Kiosk: bump versionCode 3→4, versionName 1.2.0→1.3.0 - Kiosk: add explicit signingConfigs block (debug keystore) to avoid signature mismatch on updates; update banner now shows uninstall instruction + 12s timeout - Web: v1.4.0 → v1.5.0 - Preferred use-location: remember last N location choices per product; after 3+ consistent picks auto-select and collapse location picker (with 'cambia' link) - Scale: call updateScaleReadButtons() on every status change so live-box and read button appear instantly on reconnect without manual refresh - Smart shopping cache: invalidate JSON cache file on every inventory_add and inventory_use so next shopping-page load always sees current stock - isLowStock: conf threshold changed <= 1 → < 1 (1 full pack is not low stock) - italianToBring: replace substring matching with whole-word matching (min 4 chars) to prevent 'gin' matching 'original', 'rum' matching 'crumble', etc. Philadelphia original was silently mapped to Gin and skipped as duplicate - Storico: add undo support (transaction_undo endpoint, undone column, JS undo btn) - LOG → Storico rename in UI, nav, translations - Bring! sync: urgency-aware purchased blocklist TTL (critical 30m, high 90m, others 4h) - forceSyncBring() button to clear all guards and re-sync from scratch - Scale live-box: position:fixed CSS class, 1.6rem/800 value, direct ml display - Recipe use modal: scale live-box with 10s stability + 5s auto-confirm countdown - Recipe use modal: show recipe quantity as highlighted row in Usa popup
This commit is contained in:
@@ -165,6 +165,13 @@ function migrateDB(PDO $db): void {
|
|||||||
recalcSealedFridgeExpiry($db);
|
recalcSealedFridgeExpiry($db);
|
||||||
$db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')");
|
$db->exec("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('migration_fridge_expiry_v1', '1')");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add undone column to transactions if missing
|
||||||
|
$txCols = $db->query("PRAGMA table_info(transactions)")->fetchAll();
|
||||||
|
$txColNames = array_column($txCols, 'name');
|
||||||
|
if (!in_array('undone', $txColNames)) {
|
||||||
|
$db->exec("ALTER TABLE transactions ADD COLUMN undone INTEGER DEFAULT 0");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
+164
-23
@@ -184,6 +184,10 @@ try {
|
|||||||
listTransactions($db);
|
listTransactions($db);
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'transaction_undo':
|
||||||
|
undoTransaction($db);
|
||||||
|
break;
|
||||||
|
|
||||||
// ===== STATS =====
|
// ===== STATS =====
|
||||||
case 'stats':
|
case 'stats':
|
||||||
getStats($db);
|
getStats($db);
|
||||||
@@ -846,6 +850,8 @@ function addToInventory(PDO $db): void {
|
|||||||
'package_unit' => $prodInfo['package_unit'] ?? null,
|
'package_unit' => $prodInfo['package_unit'] ?? null,
|
||||||
'removed_from_bring' => $removedFromBring,
|
'removed_from_bring' => $removedFromBring,
|
||||||
]);
|
]);
|
||||||
|
// Inventory changed — force smart-shopping recompute on next request
|
||||||
|
invalidateSmartShoppingCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
function useFromInventory(PDO $db): void {
|
function useFromInventory(PDO $db): void {
|
||||||
@@ -1083,6 +1089,8 @@ function useFromInventory(PDO $db): void {
|
|||||||
}
|
}
|
||||||
if ($openedId) $response['opened_id'] = $openedId;
|
if ($openedId) $response['opened_id'] = $openedId;
|
||||||
echo json_encode($response);
|
echo json_encode($response);
|
||||||
|
// Inventory changed — force smart-shopping recompute on next request
|
||||||
|
invalidateSmartShoppingCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateInventory(PDO $db): void {
|
function updateInventory(PDO $db): void {
|
||||||
@@ -1160,6 +1168,92 @@ function listTransactions(PDO $db): void {
|
|||||||
echo json_encode(['transactions' => $stmt->fetchAll()]);
|
echo json_encode(['transactions' => $stmt->fetchAll()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Undo a transaction (reverse its effect on inventory).
|
||||||
|
* Only available within 24 hours of the original transaction.
|
||||||
|
* - type='in' (add) → removes that quantity from inventory at the same location
|
||||||
|
* - type='out'/'waste' → adds that quantity back to inventory at the same location
|
||||||
|
* Marks the original as undone=1 and logs a counter-transaction with notes='[Annullato]'.
|
||||||
|
*/
|
||||||
|
function undoTransaction(PDO $db): void {
|
||||||
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
|
$txId = (int)($input['id'] ?? 0);
|
||||||
|
if (!$txId) {
|
||||||
|
http_response_code(400);
|
||||||
|
echo json_encode(['error' => 'Transaction ID required']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch original transaction
|
||||||
|
$stmt = $db->prepare("SELECT t.*, p.name FROM transactions t JOIN products p ON t.product_id = p.id WHERE t.id = ?");
|
||||||
|
$stmt->execute([$txId]);
|
||||||
|
$tx = $stmt->fetch();
|
||||||
|
if (!$tx) {
|
||||||
|
http_response_code(404);
|
||||||
|
echo json_encode(['error' => 'Transaction not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ($tx['undone']) {
|
||||||
|
echo json_encode(['error' => 'Transaction already undone', 'already_undone' => true]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Only allow within 24 hours
|
||||||
|
$ageSeconds = time() - strtotime($tx['created_at'] . ' UTC');
|
||||||
|
if ($ageSeconds > 86400) {
|
||||||
|
echo json_encode(['error' => 'Can only undo transactions within 24 hours', 'too_old' => true]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$db->beginTransaction();
|
||||||
|
try {
|
||||||
|
$productId = (int)$tx['product_id'];
|
||||||
|
$quantity = (float)$tx['quantity'];
|
||||||
|
$location = $tx['location'] ?: 'dispensa';
|
||||||
|
$type = $tx['type'];
|
||||||
|
|
||||||
|
if ($type === 'in') {
|
||||||
|
// Reverse an ADD: remove quantity from inventory
|
||||||
|
$stmt2 = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? AND quantity > 0 ORDER BY quantity DESC LIMIT 1");
|
||||||
|
$stmt2->execute([$productId, $location]);
|
||||||
|
$row = $stmt2->fetch();
|
||||||
|
if ($row) {
|
||||||
|
$newQty = max(0, (float)$row['quantity'] - $quantity);
|
||||||
|
if ($newQty <= 0) {
|
||||||
|
$db->prepare("DELETE FROM inventory WHERE id = ?")->execute([$row['id']]);
|
||||||
|
} else {
|
||||||
|
$db->prepare("UPDATE inventory SET quantity = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")->execute([$newQty, $row['id']]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Log counter-transaction
|
||||||
|
$db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'out', ?, ?, '[Annullato]')")->execute([$productId, $quantity, $location]);
|
||||||
|
|
||||||
|
} elseif ($type === 'out' || $type === 'waste') {
|
||||||
|
// Reverse a USE: add quantity back to inventory
|
||||||
|
$stmt2 = $db->prepare("SELECT id, quantity FROM inventory WHERE product_id = ? AND location = ? ORDER BY quantity DESC LIMIT 1");
|
||||||
|
$stmt2->execute([$productId, $location]);
|
||||||
|
$row = $stmt2->fetch();
|
||||||
|
if ($row) {
|
||||||
|
$db->prepare("UPDATE inventory SET quantity = quantity + ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?")->execute([$quantity, $row['id']]);
|
||||||
|
} else {
|
||||||
|
// No row at this location — create one without expiry
|
||||||
|
$db->prepare("INSERT INTO inventory (product_id, location, quantity) VALUES (?, ?, ?)")->execute([$productId, $location, $quantity]);
|
||||||
|
}
|
||||||
|
// Log counter-transaction
|
||||||
|
$db->prepare("INSERT INTO transactions (product_id, type, quantity, location, notes) VALUES (?, 'in', ?, ?, '[Annullato]')")->execute([$productId, $quantity, $location]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark original as undone
|
||||||
|
$db->prepare("UPDATE transactions SET undone = 1 WHERE id = ?")->execute([$txId]);
|
||||||
|
$db->commit();
|
||||||
|
echo json_encode(['success' => true, 'name' => $tx['name']]);
|
||||||
|
} catch (Exception $e) {
|
||||||
|
$db->rollBack();
|
||||||
|
http_response_code(500);
|
||||||
|
echo json_encode(['error' => 'DB error: ' . $e->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// ===== STATS =====
|
// ===== STATS =====
|
||||||
|
|
||||||
function getStats(PDO $db): void {
|
function getStats(PDO $db): void {
|
||||||
@@ -1812,6 +1906,7 @@ function generateRecipe(PDO $db): void {
|
|||||||
$input = json_decode(file_get_contents('php://input'), true);
|
$input = json_decode(file_get_contents('php://input'), true);
|
||||||
$mealType = $input['meal'] ?? 'pranzo';
|
$mealType = $input['meal'] ?? 'pranzo';
|
||||||
$persons = max(1, intval($input['persons'] ?? 1));
|
$persons = max(1, intval($input['persons'] ?? 1));
|
||||||
|
$subType = $input['sub_type'] ?? '';
|
||||||
$options = $input['options'] ?? [];
|
$options = $input['options'] ?? [];
|
||||||
$appliances = $input['appliances'] ?? [];
|
$appliances = $input['appliances'] ?? [];
|
||||||
$dietaryRestrictions = $input['dietary_restrictions'] ?? '';
|
$dietaryRestrictions = $input['dietary_restrictions'] ?? '';
|
||||||
@@ -1963,6 +2058,30 @@ function generateRecipe(PDO $db): void {
|
|||||||
];
|
];
|
||||||
$mealLabel = $mealLabels[$mealType] ?? $mealType;
|
$mealLabel = $mealLabels[$mealType] ?? $mealType;
|
||||||
|
|
||||||
|
// Sub-type specialization for dolce/succo
|
||||||
|
$subTypeLabels = [
|
||||||
|
'dolce' => [
|
||||||
|
'torta' => 'Torta (soffice, da forno: torta di mele, ciambellone, plumcake, angel cake, ecc.)',
|
||||||
|
'crema' => 'Crema o Budino (crema pasticcera, panna cotta, mousse, tiramisù, budino, semifreddo)',
|
||||||
|
'crumble' => 'Crumble o Crostata (base croccante: crumble di frutta, crostata, sbriciolata)',
|
||||||
|
'biscotti' => 'Biscotti o Pasticcini (biscotti, cookies, muffin, cupcake, pasticcini)',
|
||||||
|
'frutta' => 'Dolce alla Frutta (macedonia creativa, frutta caramellata, sorbetto, frullato dolce)',
|
||||||
|
],
|
||||||
|
'succo' => [
|
||||||
|
'dolce' => 'Succo Dolce e Fruttato (mix di frutta dolce: pesca, mela, pera, fragola, banana)',
|
||||||
|
'energizzante' => 'Succo Energizzante (con zenzero, curcuma, barbabietola, carota, mela verde)',
|
||||||
|
'detox' => 'Succo Detox / Verde (cetriolo, sedano, spinaci, mela verde, limone)',
|
||||||
|
'rinfrescante' => 'Succo Rinfrescante (anguria, menta, lime, cetriolo, acqua di cocco)',
|
||||||
|
'vitaminico' => 'Succo Vitaminico / Agrumi (arancia, pompelmo, limone, kiwi, mandarino)',
|
||||||
|
]
|
||||||
|
];
|
||||||
|
$subTypeText = '';
|
||||||
|
if (!empty($subType) && isset($subTypeLabels[$mealType][$subType])) {
|
||||||
|
$subHint = $subTypeLabels[$mealType][$subType];
|
||||||
|
$mealLabel .= " — tipo: $subHint";
|
||||||
|
$subTypeText = "\n\n🎨 SOTTO-TIPO RICHIESTO:\nL'utente ha scelto specificamente: {$subHint}\nLa ricetta DEVE essere di questo tipo preciso. Non proporre un tipo diverso di {$mealType}.";
|
||||||
|
}
|
||||||
|
|
||||||
// Build extra rules from options
|
// Build extra rules from options
|
||||||
$extraRules = [];
|
$extraRules = [];
|
||||||
$optionLabels = [
|
$optionLabels = [
|
||||||
@@ -2123,7 +2242,7 @@ function generateRecipe(PDO $db): void {
|
|||||||
|
|
||||||
$prompt = <<<PROMPT
|
$prompt = <<<PROMPT
|
||||||
Sei un nutrizionista e chef italiano esperto. Genera UNA ricetta per $mealLabel per $persons persona/e usando PRINCIPALMENTE gli ingredienti disponibili nella dispensa dell'utente.
|
Sei un nutrizionista e chef italiano esperto. Genera UNA ricetta per $mealLabel per $persons persona/e usando PRINCIPALMENTE gli ingredienti disponibili nella dispensa dell'utente.
|
||||||
{$extraRulesText}{$appliancesText}{$dietaryText}{$mealPlanText}{$varietyText}{$regenText}{$mustUseText}
|
{$extraRulesText}{$appliancesText}{$dietaryText}{$subTypeText}{$mealPlanText}{$varietyText}{$regenText}{$mustUseText}
|
||||||
|
|
||||||
REGOLE IMPORTANTI:
|
REGOLE IMPORTANTI:
|
||||||
{$mealPlanRule}1. ORDINE DI PRIORITÀ INGREDIENTI (dal più urgente al meno urgente) — gli ingredienti nella lista sono già ordinati per priorità:
|
{$mealPlanRule}1. ORDINE DI PRIORITÀ INGREDIENTI (dal più urgente al meno urgente) — gli ingredienti nella lista sono già ordinati per priorità:
|
||||||
@@ -2683,30 +2802,28 @@ function italianToBring(string $italianName): string {
|
|||||||
$catalog = bringCatalog();
|
$catalog = bringCatalog();
|
||||||
$lower = mb_strtolower(trim($italianName));
|
$lower = mb_strtolower(trim($italianName));
|
||||||
|
|
||||||
// Exact match
|
// Pass 1: exact match
|
||||||
if (isset($catalog['it2de'][$lower])) {
|
if (isset($catalog['it2de'][$lower])) {
|
||||||
return $catalog['it2de'][$lower];
|
return $catalog['it2de'][$lower];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try partial match: "Spinaci freschi" → match "Spinaci"
|
// Pass 2: whole-word match — catalog key must be a whole word inside the input.
|
||||||
|
// Uses word-boundary logic (split on spaces) to avoid substring false positives like
|
||||||
|
// "gin" inside "original", "rum" inside "crumble", "aceto" inside "pancetta", etc.
|
||||||
|
// Only considers single-word catalog keys (multi-word keys need Pass 1 exact match).
|
||||||
|
$inputWords = array_filter(
|
||||||
|
preg_split('/\s+/', $lower),
|
||||||
|
fn($w) => mb_strlen($w) >= 4 // skip very short words — too ambiguous
|
||||||
|
);
|
||||||
foreach ($catalog['it2de'] as $itLower => $deKey) {
|
foreach ($catalog['it2de'] as $itLower => $deKey) {
|
||||||
if (str_contains($lower, $itLower) || str_contains($itLower, $lower)) {
|
if (str_contains($itLower, ' ')) continue; // multi-word key → exact-only
|
||||||
|
if (mb_strlen($itLower) < 4) continue; // too short → skip (gin, rum, etc.)
|
||||||
|
if (in_array($itLower, $inputWords, true)) {
|
||||||
return $deKey;
|
return $deKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try matching first word: "Petto di pollo" → "Pollo" = Poulet
|
// No match — return the original Italian name so Bring! shows it as a custom item
|
||||||
$words = explode(' ', $lower);
|
|
||||||
foreach ($words as $word) {
|
|
||||||
if (mb_strlen($word) < 3) continue;
|
|
||||||
foreach ($catalog['it2de'] as $itLower => $deKey) {
|
|
||||||
if ($itLower === $word) {
|
|
||||||
return $deKey;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// No match - return original (Bring! will show as custom item)
|
|
||||||
return $italianName;
|
return $italianName;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2917,6 +3034,17 @@ function bringCleanSpecs(): void {
|
|||||||
* Serve smart shopping from cache (written by cron), falling back to live computation.
|
* Serve smart shopping from cache (written by cron), falling back to live computation.
|
||||||
* Cache is valid for up to 10 minutes; if stale or missing, compute on the fly.
|
* Cache is valid for up to 10 minutes; if stale or missing, compute on the fly.
|
||||||
*/
|
*/
|
||||||
|
/**
|
||||||
|
* Invalidate the smart shopping cache so the next request recomputes live.
|
||||||
|
* Call after any inventory_add or inventory_use that changes stock meaningfully.
|
||||||
|
*/
|
||||||
|
function invalidateSmartShoppingCache(): void {
|
||||||
|
$cacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||||
|
if (file_exists($cacheFile)) {
|
||||||
|
@unlink($cacheFile);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function smartShoppingCached(PDO $db): void {
|
function smartShoppingCached(PDO $db): void {
|
||||||
$cacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
|
$cacheFile = __DIR__ . '/../data/smart_shopping_cache.json';
|
||||||
$maxAge = 10 * 60; // 10 minutes
|
$maxAge = 10 * 60; // 10 minutes
|
||||||
@@ -3140,11 +3268,16 @@ function smartShopping(PDO $db): void {
|
|||||||
|
|
||||||
// Out of stock
|
// Out of stock
|
||||||
if ($qty <= 0) {
|
if ($qty <= 0) {
|
||||||
// If ANY significant token of this depleted product also appears in an in-stock product,
|
// If ANY *specific* token of this depleted product also appears in an in-stock product,
|
||||||
// the user's need is already covered — skip flagging it.
|
// the user's need is already covered — skip flagging it.
|
||||||
// Examples: 'Passata di pomodoro' depleted, 'Polpa di pomodoro' in stock → share 'pomodoro' → skip
|
// Generic preparation/type words (succo, polpa, crema, ecc.) are excluded from this check
|
||||||
// 'Aglio rosso' depleted, 'Aglio' in stock → share 'aglio' → skip
|
// to avoid false coverage: 'limmi succo di limone' must NOT be suppressed by 'Succo e polpa di pera'.
|
||||||
$pToks = $nameTokens($p['name']);
|
// A token must appear in both names AND be specific (not in the generic list) to count.
|
||||||
|
$coverageGeneric = ['succo','polpa','crema','salsa','frutta','verdura','intero',
|
||||||
|
'parzialmente','scremato','biologico','naturale','integrale',
|
||||||
|
'cotto','fresco','secco','arrostito','bollito','sgusciato',
|
||||||
|
'bianco','rosso','nero','giallo','verde','misto','dolce','light'];
|
||||||
|
$pToks = array_diff($nameTokens($p['name']), $coverageGeneric);
|
||||||
$coveredByEquivalent = false;
|
$coveredByEquivalent = false;
|
||||||
foreach ($pToks as $tok) {
|
foreach ($pToks as $tok) {
|
||||||
if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; }
|
if (($stockByAnyToken[$tok] ?? 0) > 0) { $coveredByEquivalent = true; break; }
|
||||||
@@ -3157,10 +3290,18 @@ function smartShopping(PDO $db): void {
|
|||||||
$reasons[] = 'Esaurito';
|
$reasons[] = 'Esaurito';
|
||||||
$score += 100;
|
$score += 100;
|
||||||
if ($useCount >= 5) { $score += 20; $reasons[] = "Uso frequente ({$useCount}x)"; }
|
if ($useCount >= 5) { $score += 20; $reasons[] = "Uso frequente ({$useCount}x)"; }
|
||||||
|
} elseif ($isFrequent && $isRecent && $buyCount == 1 && $useCount >= 3) {
|
||||||
|
// Bought once but used ≥3 times → proven consumption pattern → high
|
||||||
|
$urgency = 'high';
|
||||||
|
$reasons[] = 'Esaurito';
|
||||||
|
$score += 75;
|
||||||
|
if ($useCount >= 5) { $score += 10; $reasons[] = "Uso frequente ({$useCount}x)"; }
|
||||||
} elseif ($isFrequent && $isRecent && $buyCount == 1) {
|
} elseif ($isFrequent && $isRecent && $buyCount == 1) {
|
||||||
// Frequent use but only bought once — not yet a proven staple → skip
|
// Frequent use, bought once, <3 uses — not yet proven → medium
|
||||||
continue;
|
$urgency = 'medium';
|
||||||
} elseif ($isRegular && $isRecent && ($useCount >= 4 || $buyCount >= 3)) {
|
$reasons[] = 'Esaurito';
|
||||||
|
$score += 45;
|
||||||
|
} elseif ($isRegular && $isRecent && ($useCount >= 3 || $buyCount >= 2)) {
|
||||||
// Regularly used, recently active → high
|
// Regularly used, recently active → high
|
||||||
$urgency = 'high';
|
$urgency = 'high';
|
||||||
$reasons[] = 'Esaurito';
|
$reasons[] = 'Esaurito';
|
||||||
|
|||||||
+87
-2
@@ -853,8 +853,8 @@ body {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
.scale-live-val {
|
.scale-live-val {
|
||||||
font-size: 1.15rem;
|
font-size: 1.6rem;
|
||||||
font-weight: 700;
|
font-weight: 800;
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
}
|
}
|
||||||
@@ -2895,6 +2895,37 @@ body {
|
|||||||
text-underline-offset: 2px;
|
text-underline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Preferred use-location (collapsed row) ── */
|
||||||
|
.pref-loc-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
background: var(--bg-card);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.pref-loc-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 6px;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
.pref-loc-full-btns {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== PRODUCT DETAILS CARD (Action Page) ===== */
|
/* ===== PRODUCT DETAILS CARD (Action Page) ===== */
|
||||||
.product-details-card {
|
.product-details-card {
|
||||||
background: var(--bg-card);
|
background: var(--bg-card);
|
||||||
@@ -3855,6 +3886,43 @@ body {
|
|||||||
background: rgba(52, 120, 246, 0.06);
|
background: rgba(52, 120, 246, 0.06);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.log-entry.log-undone {
|
||||||
|
opacity: 0.45;
|
||||||
|
filter: grayscale(60%);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-undone-badge {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
background: var(--text-muted);
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 1px 5px;
|
||||||
|
text-decoration: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-log-undo {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: none;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-muted);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 3px 8px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.4;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
}
|
||||||
|
.btn-log-undo:hover {
|
||||||
|
background: rgba(255,255,255,0.08);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.log-icon {
|
.log-icon {
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@@ -4186,6 +4254,23 @@ body {
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Sub-type grid for dolce/succo */
|
||||||
|
.recipe-subtype-grid {
|
||||||
|
margin-top: 8px;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
animation: bannerSlideIn 0.25s ease-out;
|
||||||
|
}
|
||||||
|
.recipe-subtype-chip {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 8px 6px;
|
||||||
|
background: #fef9ef;
|
||||||
|
border-color: #f0e4cc;
|
||||||
|
}
|
||||||
|
.recipe-subtype-chip:has(input:checked) {
|
||||||
|
background: #fff3d4;
|
||||||
|
border-color: #e6a817;
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== LARGER PRODUCT PREVIEW (Action page) ===== */
|
/* ===== LARGER PRODUCT PREVIEW (Action page) ===== */
|
||||||
.product-preview-large {
|
.product-preview-large {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
+302
-34
@@ -116,6 +116,9 @@ function _scaleOnMessage(msg) {
|
|||||||
_scaleDevice = msg.device || null;
|
_scaleDevice = msg.device || null;
|
||||||
_scaleBattery = msg.battery ?? null;
|
_scaleBattery = msg.battery ?? null;
|
||||||
_scaleUpdateStatus(_scaleConnected ? 'connected' : 'searching');
|
_scaleUpdateStatus(_scaleConnected ? 'connected' : 'searching');
|
||||||
|
// Refresh all scale UI elements immediately so buttons/live-box appear
|
||||||
|
// without requiring a manual page refresh
|
||||||
|
updateScaleReadButtons();
|
||||||
} else if (msg.type === 'weight') {
|
} else if (msg.type === 'weight') {
|
||||||
// Ignore negative weight values (tare artifacts, sensor noise)
|
// Ignore negative weight values (tare artifacts, sensor noise)
|
||||||
if (parseFloat(msg.value) < 0) return;
|
if (parseFloat(msg.value) < 0) return;
|
||||||
@@ -214,21 +217,26 @@ function _scaleUpdateLiveBox(msg) {
|
|||||||
} else {
|
} else {
|
||||||
box.classList.remove('scale-low-weight');
|
box.classList.remove('scale-low-weight');
|
||||||
const stIcon = msg.stable ? ' ✓' : ' …';
|
const stIcon = msg.stable ? ' ✓' : ' …';
|
||||||
if (valEl) valEl.textContent = `${isFinite(raw) ? raw : '—'} ${msg.unit || 'kg'}${stIcon}`;
|
// Show converted ML if target unit is ml (instead of raw grams)
|
||||||
// Show conversion hint when product unit is ml
|
let displayVal = `${isFinite(raw) ? raw : '—'} ${msg.unit || 'kg'}`;
|
||||||
let targetUnit = null;
|
let targetUnit = null;
|
||||||
if (_useConfMode && _useConfMode._activeUnit === 'sub') {
|
if (_useConfMode && _useConfMode._activeUnit === 'sub') {
|
||||||
targetUnit = (_useConfMode.packageUnit || '').toLowerCase();
|
targetUnit = (_useConfMode.packageUnit || '').toLowerCase();
|
||||||
} else {
|
} else {
|
||||||
targetUnit = _useNormalUnit;
|
targetUnit = _useNormalUnit;
|
||||||
}
|
}
|
||||||
if (lblEl) {
|
if (targetUnit === 'ml' && rawUnit !== 'ml' && isFinite(raw) && raw > 0) {
|
||||||
if (targetUnit === 'ml' && rawUnit !== 'ml') {
|
let grams = raw;
|
||||||
lblEl.textContent = '⚖️ Peso in grammi → verrà convertito in ml';
|
if (rawUnit === 'kg') grams = raw * 1000;
|
||||||
lblEl.style.display = '';
|
else if (rawUnit === 'lbs' || rawUnit === 'lb') grams = raw * 453.592;
|
||||||
} else {
|
else if (rawUnit === 'oz') grams = raw * 28.3495;
|
||||||
lblEl.textContent = '';
|
const density = _scaleDensityForProduct(currentProduct);
|
||||||
|
const ml = Math.round(grams / density);
|
||||||
|
displayVal = `${ml} ml`;
|
||||||
}
|
}
|
||||||
|
if (valEl) valEl.textContent = displayVal + stIcon;
|
||||||
|
if (lblEl) {
|
||||||
|
lblEl.textContent = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -384,6 +392,21 @@ function _scaleAutoFillRecipeUse(msg) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Update live box in modal — show the already-converted value in the target unit
|
||||||
|
const livVal = document.getElementById('ruse-scale-live-val');
|
||||||
|
const livLabel = document.getElementById('ruse-scale-live-label');
|
||||||
|
const livStatus = document.getElementById('ruse-scale-live-status');
|
||||||
|
if (livVal) {
|
||||||
|
// val is already converted to target unit (g or ml); show it directly
|
||||||
|
if (val >= 10) {
|
||||||
|
livVal.textContent = `${val} ${unit}`;
|
||||||
|
} else {
|
||||||
|
// val not usable yet — show raw reading
|
||||||
|
livVal.textContent = `${msg.value} ${msg.unit || 'kg'}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (livStatus) livStatus.textContent = msg.stable ? '✓ Stabile' : '…';
|
||||||
|
|
||||||
// Update live hint in modal with the raw scale reading always
|
// Update live hint in modal with the raw scale reading always
|
||||||
const hint = document.getElementById('ruse-scale-hint');
|
const hint = document.getElementById('ruse-scale-hint');
|
||||||
if (hint) {
|
if (hint) {
|
||||||
@@ -396,6 +419,7 @@ function _scaleAutoFillRecipeUse(msg) {
|
|||||||
|
|
||||||
if (val < 10) {
|
if (val < 10) {
|
||||||
_cancelScaleStabilityWait(); // stop bar only; keep sentinel
|
_cancelScaleStabilityWait(); // stop bar only; keep sentinel
|
||||||
|
if (livLabel) livLabel.textContent = 'Peso troppo basso — attendi…';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -408,6 +432,10 @@ function _scaleAutoFillRecipeUse(msg) {
|
|||||||
_scaleStabilityVal = val;
|
_scaleStabilityVal = val;
|
||||||
_scaleUserDismissed = false;
|
_scaleUserDismissed = false;
|
||||||
_cancelScaleTimersOnly();
|
_cancelScaleTimersOnly();
|
||||||
|
if (livLabel) livLabel.textContent = 'Peso rilevato — attendi 10s di stabilità…';
|
||||||
|
// Hide confirm bar when new value arrives
|
||||||
|
const confirmWrap = document.getElementById('ruse-scale-confirm-wrap');
|
||||||
|
if (confirmWrap) confirmWrap.style.display = 'none';
|
||||||
_startScaleStabilityWait(() => {
|
_startScaleStabilityWait(() => {
|
||||||
const inp = document.getElementById('ruse-quantity');
|
const inp = document.getElementById('ruse-quantity');
|
||||||
if (inp) inp.value = val;
|
if (inp) inp.value = val;
|
||||||
@@ -415,14 +443,35 @@ function _scaleAutoFillRecipeUse(msg) {
|
|||||||
hint.textContent = `⚖️ Peso bilancia: ${val} ${unit}${hintExtra}`;
|
hint.textContent = `⚖️ Peso bilancia: ${val} ${unit}${hintExtra}`;
|
||||||
hint.style.display = '';
|
hint.style.display = '';
|
||||||
}
|
}
|
||||||
_startScaleAutoConfirm(() => { _scaleLastConfirmedGrams = grams; submitRecipeUse(false); }, 'btn-ruse-submit');
|
if (livLabel) livLabel.textContent = `✅ ${val} ${unit} — conferma automatica tra 5s (tocca per annullare)`;
|
||||||
|
if (livVal) livVal.style.color = '#22c55e';
|
||||||
|
const confirmWrap2 = document.getElementById('ruse-scale-confirm-wrap');
|
||||||
|
if (confirmWrap2) { confirmWrap2.style.display = ''; }
|
||||||
|
const confirmBar = document.getElementById('ruse-scale-confirm-bar');
|
||||||
|
if (confirmBar) confirmBar.style.width = '100%';
|
||||||
|
_startScaleAutoConfirm(() => {
|
||||||
|
_scaleLastConfirmedGrams = grams;
|
||||||
|
if (livVal) livVal.style.color = '';
|
||||||
|
submitRecipeUse(false);
|
||||||
|
}, 'btn-ruse-submit');
|
||||||
});
|
});
|
||||||
} else if (!_scaleUserDismissed && !_scaleStabilityTimer && !_scaleAutoConfirmTimer) {
|
} else if (!_scaleUserDismissed && !_scaleStabilityTimer && !_scaleAutoConfirmTimer) {
|
||||||
_cancelScaleTimersOnly();
|
_cancelScaleTimersOnly();
|
||||||
|
if (livLabel) livLabel.textContent = 'Peso rilevato — attendi 10s di stabilità…';
|
||||||
_startScaleStabilityWait(() => {
|
_startScaleStabilityWait(() => {
|
||||||
const inp = document.getElementById('ruse-quantity');
|
const inp = document.getElementById('ruse-quantity');
|
||||||
if (inp) inp.value = val;
|
if (inp) inp.value = val;
|
||||||
_startScaleAutoConfirm(() => { _scaleLastConfirmedGrams = grams; submitRecipeUse(false); }, 'btn-ruse-submit');
|
if (livLabel) livLabel.textContent = `✅ ${val} ${unit} — conferma automatica tra 5s (tocca per annullare)`;
|
||||||
|
if (livVal) livVal.style.color = '#22c55e';
|
||||||
|
const confirmWrap3 = document.getElementById('ruse-scale-confirm-wrap');
|
||||||
|
if (confirmWrap3) confirmWrap3.style.display = '';
|
||||||
|
const confirmBar2 = document.getElementById('ruse-scale-confirm-bar');
|
||||||
|
if (confirmBar2) confirmBar2.style.width = '100%';
|
||||||
|
_startScaleAutoConfirm(() => {
|
||||||
|
_scaleLastConfirmedGrams = grams;
|
||||||
|
if (livVal) livVal.style.color = '';
|
||||||
|
submitRecipeUse(false);
|
||||||
|
}, 'btn-ruse-submit');
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -445,6 +494,17 @@ function _cancelScaleTimersOnly() {
|
|||||||
const ruseBtn = document.getElementById('btn-ruse-submit');
|
const ruseBtn = document.getElementById('btn-ruse-submit');
|
||||||
if (useBtn) useBtn.style.background = '';
|
if (useBtn) useBtn.style.background = '';
|
||||||
if (ruseBtn) ruseBtn.style.background = '';
|
if (ruseBtn) ruseBtn.style.background = '';
|
||||||
|
// Reset modal confirm bar and live val colour
|
||||||
|
const confirmBar = document.getElementById('ruse-scale-confirm-bar');
|
||||||
|
const livVal = document.getElementById('ruse-scale-live-val');
|
||||||
|
const confirmWrap = document.getElementById('ruse-scale-confirm-wrap');
|
||||||
|
if (confirmBar) { confirmBar.style.width = '100%'; }
|
||||||
|
if (confirmWrap) confirmWrap.style.display = 'none';
|
||||||
|
if (livVal) livVal.style.color = '';
|
||||||
|
const livLabel = document.getElementById('ruse-scale-live-label');
|
||||||
|
if (livLabel && livLabel.textContent.startsWith('✅')) {
|
||||||
|
livLabel.textContent = 'Annullato — rimetti l\'ingrediente sulla bilancia per riprendere';
|
||||||
|
}
|
||||||
document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true);
|
document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -463,16 +523,19 @@ function _cancelScaleAutoConfirm(fromTouch) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Stop the stability wait and reset its progress bar. */
|
/** Stop the stability wait and reset its progress bar(s). */
|
||||||
function _cancelScaleStabilityWait() {
|
function _cancelScaleStabilityWait() {
|
||||||
if (_scaleStabilityTimer) { clearTimeout(_scaleStabilityTimer); _scaleStabilityTimer = null; }
|
if (_scaleStabilityTimer) { clearTimeout(_scaleStabilityTimer); _scaleStabilityTimer = null; }
|
||||||
if (_scaleStabilityRAF) { cancelAnimationFrame(_scaleStabilityRAF); _scaleStabilityRAF = null; }
|
if (_scaleStabilityRAF) { cancelAnimationFrame(_scaleStabilityRAF); _scaleStabilityRAF = null; }
|
||||||
const bar = document.getElementById('scale-live-progress-bar');
|
const bar = document.getElementById('scale-live-progress-bar');
|
||||||
|
const bar2 = document.getElementById('ruse-scale-progress-bar');
|
||||||
if (bar) bar.style.width = '0%';
|
if (bar) bar.style.width = '0%';
|
||||||
|
if (bar2) bar2.style.width = '0%';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Start a 10-second stability wait with an animated progress bar in the live box.
|
* Start a 10-second stability wait with an animated progress bar.
|
||||||
|
* Updates both #scale-live-progress-bar (use page) and #ruse-scale-progress-bar (recipe modal).
|
||||||
* Calls onStable() when weight unchanged for 10 s.
|
* Calls onStable() when weight unchanged for 10 s.
|
||||||
*/
|
*/
|
||||||
function _startScaleStabilityWait(onStable) {
|
function _startScaleStabilityWait(onStable) {
|
||||||
@@ -480,10 +543,12 @@ function _startScaleStabilityWait(onStable) {
|
|||||||
const duration = 10000;
|
const duration = 10000;
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
const bar = document.getElementById('scale-live-progress-bar');
|
const bar = document.getElementById('scale-live-progress-bar');
|
||||||
|
const bar2 = document.getElementById('ruse-scale-progress-bar');
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
const pct = Math.min(100, ((performance.now() - start) / duration) * 100);
|
const pct = Math.min(100, ((performance.now() - start) / duration) * 100);
|
||||||
if (bar) bar.style.width = pct + '%';
|
if (bar) bar.style.width = pct + '%';
|
||||||
|
if (bar2) bar2.style.width = pct + '%';
|
||||||
if (pct < 100) { _scaleStabilityRAF = requestAnimationFrame(tick); }
|
if (pct < 100) { _scaleStabilityRAF = requestAnimationFrame(tick); }
|
||||||
}
|
}
|
||||||
_scaleStabilityRAF = requestAnimationFrame(tick);
|
_scaleStabilityRAF = requestAnimationFrame(tick);
|
||||||
@@ -492,6 +557,7 @@ function _startScaleStabilityWait(onStable) {
|
|||||||
_scaleStabilityTimer = null;
|
_scaleStabilityTimer = null;
|
||||||
if (_scaleStabilityRAF) { cancelAnimationFrame(_scaleStabilityRAF); _scaleStabilityRAF = null; }
|
if (_scaleStabilityRAF) { cancelAnimationFrame(_scaleStabilityRAF); _scaleStabilityRAF = null; }
|
||||||
if (bar) bar.style.width = '0%';
|
if (bar) bar.style.width = '0%';
|
||||||
|
if (bar2) bar2.style.width = '0%';
|
||||||
onStable();
|
onStable();
|
||||||
}, duration);
|
}, duration);
|
||||||
}
|
}
|
||||||
@@ -500,16 +566,21 @@ function _startScaleAutoConfirm(onConfirm, btnId) {
|
|||||||
if (_scaleAutoConfirmRAF) { cancelAnimationFrame(_scaleAutoConfirmRAF); _scaleAutoConfirmRAF = null; }
|
if (_scaleAutoConfirmRAF) { cancelAnimationFrame(_scaleAutoConfirmRAF); _scaleAutoConfirmRAF = null; }
|
||||||
const btn = btnId ? document.getElementById(btnId) : null;
|
const btn = btnId ? document.getElementById(btnId) : null;
|
||||||
const baseBg = btn ? getComputedStyle(btn).backgroundColor : '';
|
const baseBg = btn ? getComputedStyle(btn).backgroundColor : '';
|
||||||
|
// Also update the modal countdown bar if present
|
||||||
|
const ruseCountdownBar = document.getElementById('ruse-scale-confirm-bar');
|
||||||
const duration = 5000;
|
const duration = 5000;
|
||||||
const start = performance.now();
|
const start = performance.now();
|
||||||
|
|
||||||
function tick() {
|
function tick() {
|
||||||
const elapsed = performance.now() - start;
|
const elapsed = performance.now() - start;
|
||||||
const pct = Math.min(100, (elapsed / duration) * 100);
|
const pct = Math.min(100, (elapsed / duration) * 100);
|
||||||
|
// Reverse (countdown): button fill shrinks from right to left
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.style.background =
|
btn.style.background =
|
||||||
`linear-gradient(to right, rgba(255,255,255,0.35) ${pct}%, rgba(255,255,255,0) ${pct}%), ${baseBg}`;
|
`linear-gradient(to left, rgba(255,255,255,0.35) ${100 - pct}%, rgba(255,255,255,0) ${100 - pct}%), ${baseBg}`;
|
||||||
}
|
}
|
||||||
|
// Modal countdown progress bar shrinks
|
||||||
|
if (ruseCountdownBar) ruseCountdownBar.style.width = (100 - pct) + '%';
|
||||||
if (elapsed < duration) { _scaleAutoConfirmRAF = requestAnimationFrame(tick); }
|
if (elapsed < duration) { _scaleAutoConfirmRAF = requestAnimationFrame(tick); }
|
||||||
}
|
}
|
||||||
_scaleAutoConfirmRAF = requestAnimationFrame(tick);
|
_scaleAutoConfirmRAF = requestAnimationFrame(tick);
|
||||||
@@ -517,6 +588,7 @@ function _startScaleAutoConfirm(onConfirm, btnId) {
|
|||||||
_scaleAutoConfirmTimer = setTimeout(() => {
|
_scaleAutoConfirmTimer = setTimeout(() => {
|
||||||
_scaleAutoConfirmTimer = null;
|
_scaleAutoConfirmTimer = null;
|
||||||
if (btn) btn.style.background = '';
|
if (btn) btn.style.background = '';
|
||||||
|
if (ruseCountdownBar) ruseCountdownBar.style.width = '0%';
|
||||||
document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true);
|
document.removeEventListener('pointerdown', _cancelScaleAutoConfirmOnTouch, true);
|
||||||
onConfirm();
|
onConfirm();
|
||||||
}, duration);
|
}, duration);
|
||||||
@@ -2823,11 +2895,14 @@ function filterLocation(loc) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterInventory() {
|
function filterInventory() {
|
||||||
const q = document.getElementById('inventory-search').value.toLowerCase();
|
const q = document.getElementById('inventory-search').value.toLowerCase().trim();
|
||||||
|
const qas = document.getElementById('quick-access-section');
|
||||||
if (!q) {
|
if (!q) {
|
||||||
|
if (qas) qas.style.display = '';
|
||||||
renderInventory(currentInventory);
|
renderInventory(currentInventory);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (qas) qas.style.display = 'none';
|
||||||
const filtered = currentInventory.filter(i =>
|
const filtered = currentInventory.filter(i =>
|
||||||
i.name.toLowerCase().includes(q) ||
|
i.name.toLowerCase().includes(q) ||
|
||||||
(i.brand && i.brand.toLowerCase().includes(q)) ||
|
(i.brand && i.brand.toLowerCase().includes(q)) ||
|
||||||
@@ -5369,20 +5444,40 @@ async function loadUseInventoryInfo() {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
const firstLoc = openedItem ? openedItem.location : items[0].location;
|
const firstLoc = openedItem ? openedItem.location : items[0].location;
|
||||||
document.getElementById('use-location').value = firstLoc;
|
|
||||||
|
|
||||||
// Build location buttons only for locations where the product exists
|
// Build location buttons only for locations where the product exists
|
||||||
const productLocations = [...new Set(items.map(i => i.location))];
|
const productLocations = [...new Set(items.map(i => i.location))];
|
||||||
const locSelector = document.getElementById('use-location-selector');
|
const locSelector = document.getElementById('use-location-selector');
|
||||||
locSelector.innerHTML = productLocations.map(loc => {
|
|
||||||
|
// Prefer the remembered location (if confirmed), else use the opened-package heuristic
|
||||||
|
const prefLoc = _getPreferredUseLocation(currentProduct.id);
|
||||||
|
const activeLoc = (prefLoc && productLocations.includes(prefLoc)) ? prefLoc : firstLoc;
|
||||||
|
document.getElementById('use-location').value = activeLoc;
|
||||||
|
|
||||||
|
// Builder for the full set of location buttons
|
||||||
|
const buildLocButtons = (active) => productLocations.map(loc => {
|
||||||
const locInfo = LOCATIONS[loc] || { icon: '📦', label: loc };
|
const locInfo = LOCATIONS[loc] || { icon: '📦', label: loc };
|
||||||
const locItems = items.filter(i => i.location === loc);
|
const locItems = items.filter(i => i.location === loc);
|
||||||
const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
|
const locQty = locItems.reduce((s, i) => s + parseFloat(i.quantity), 0);
|
||||||
const u = locItems[0].unit || 'pz';
|
const u = locItems[0].unit || 'pz';
|
||||||
const qtyLabel = formatQuantity(locQty, u, locItems[0].default_quantity, locItems[0].package_unit);
|
const qtyLabel = formatQuantity(locQty, u, locItems[0].default_quantity, locItems[0].package_unit);
|
||||||
return `<button type="button" class="loc-btn ${loc === firstLoc ? 'active' : ''}" onclick="selectUseLocation(this, '${loc}')">${locInfo.icon} ${locInfo.label} (${qtyLabel})</button>`;
|
return `<button type="button" class="loc-btn ${loc === active ? 'active' : ''}" onclick="selectUseLocation(this, '${loc}')">${locInfo.icon} ${locInfo.label} (${qtyLabel})</button>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
||||||
|
if (prefLoc && productLocations.includes(prefLoc) && productLocations.length > 1) {
|
||||||
|
// Confirmed preference → show collapsed row + hidden full picker
|
||||||
|
const locInfo = LOCATIONS[prefLoc] || { icon: '📦', label: prefLoc };
|
||||||
|
locSelector.innerHTML = `
|
||||||
|
<div class="pref-loc-info" id="pref-loc-info">
|
||||||
|
<span class="pref-loc-name">${locInfo.icon} ${locInfo.label}</span>
|
||||||
|
<button type="button" class="btn-link pref-loc-change" onclick="_expandUseLocationSelector()">cambia</button>
|
||||||
|
</div>
|
||||||
|
<div id="pref-loc-full" style="display:none">${buildLocButtons(activeLoc)}</div>
|
||||||
|
`;
|
||||||
|
} else {
|
||||||
|
locSelector.innerHTML = buildLocButtons(activeLoc);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
const unit = items[0].unit || 'pz';
|
const unit = items[0].unit || 'pz';
|
||||||
const pkgSize = parseFloat(items[0].default_quantity) || 0;
|
const pkgSize = parseFloat(items[0].default_quantity) || 0;
|
||||||
@@ -5526,6 +5621,47 @@ function selectUseLocation(btn, loc) {
|
|||||||
document.getElementById('use-location').value = loc;
|
document.getElementById('use-location').value = loc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── PREFERRED USE LOCATION ───────────────────────────────────────────────
|
||||||
|
// After 3+ consistent choices from the same location for a product,
|
||||||
|
// auto-selects it and hides the location picker (user can still tap "cambia").
|
||||||
|
const _PREF_LOC_KEY = '_prefUseLoc';
|
||||||
|
const _PREF_LOC_NEEDED = 3; // choices needed to confirm a preference
|
||||||
|
|
||||||
|
function _getPrefLocHistory(productId) {
|
||||||
|
try {
|
||||||
|
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
|
||||||
|
return all[String(productId)] || [];
|
||||||
|
} catch { return []; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _recordUseLocationChoice(productId, loc) {
|
||||||
|
try {
|
||||||
|
const all = JSON.parse(localStorage.getItem(_PREF_LOC_KEY) || '{}');
|
||||||
|
const key = String(productId);
|
||||||
|
const hist = all[key] || [];
|
||||||
|
hist.push(loc);
|
||||||
|
if (hist.length > 8) hist.splice(0, hist.length - 8); // keep last 8
|
||||||
|
all[key] = hist;
|
||||||
|
localStorage.setItem(_PREF_LOC_KEY, JSON.stringify(all));
|
||||||
|
} catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
function _getPreferredUseLocation(productId) {
|
||||||
|
const hist = _getPrefLocHistory(productId);
|
||||||
|
if (hist.length < _PREF_LOC_NEEDED) return null;
|
||||||
|
const recent = hist.slice(-5); // look at last 5
|
||||||
|
const counts = {};
|
||||||
|
for (const loc of recent) counts[loc] = (counts[loc] || 0) + 1;
|
||||||
|
const [topLoc, topCount] = Object.entries(counts).sort((a, b) => b[1] - a[1])[0];
|
||||||
|
return topCount >= _PREF_LOC_NEEDED ? topLoc : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _expandUseLocationSelector() {
|
||||||
|
document.getElementById('pref-loc-info')?.style.setProperty('display', 'none');
|
||||||
|
document.getElementById('pref-loc-full')?.style.removeProperty('display');
|
||||||
|
}
|
||||||
|
// ────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function setPzFraction(frac) {
|
function setPzFraction(frac) {
|
||||||
document.getElementById('use-quantity').value = frac;
|
document.getElementById('use-quantity').value = frac;
|
||||||
document.querySelectorAll('#pz-fraction-btns .frac-btn').forEach(b => {
|
document.querySelectorAll('#pz-fraction-btns .frac-btn').forEach(b => {
|
||||||
@@ -5537,7 +5673,7 @@ function setPzFraction(frac) {
|
|||||||
function isLowStock(totalRemaining, unit, defaultQty) {
|
function isLowStock(totalRemaining, unit, defaultQty) {
|
||||||
if (totalRemaining <= 0) return true; // fully depleted → definitely needs restocking
|
if (totalRemaining <= 0) return true; // fully depleted → definitely needs restocking
|
||||||
if (unit === 'pz') return totalRemaining <= 1; // only 1 piece left
|
if (unit === 'pz') return totalRemaining <= 1; // only 1 piece left
|
||||||
if (unit === 'conf') return totalRemaining <= 1;
|
if (unit === 'conf') return totalRemaining < 1; // only warn when less than 1 full pack remains (opened/partial)
|
||||||
// Weight/volume: use percentage of default_qty or fixed threshold
|
// Weight/volume: use percentage of default_qty or fixed threshold
|
||||||
if (defaultQty > 0) return totalRemaining <= defaultQty * 0.25;
|
if (defaultQty > 0) return totalRemaining <= defaultQty * 0.25;
|
||||||
// Fallback fixed thresholds
|
// Fallback fixed thresholds
|
||||||
@@ -5860,6 +5996,7 @@ async function submitUse(e) {
|
|||||||
}
|
}
|
||||||
// If there's remaining quantity, offer to move to another location
|
// If there's remaining quantity, offer to move to another location
|
||||||
const usedFrom = document.getElementById('use-location').value;
|
const usedFrom = document.getElementById('use-location').value;
|
||||||
|
_recordUseLocationChoice(currentProduct.id, usedFrom); // track for preferred-location feature
|
||||||
const moveCallback = result.remaining > 0
|
const moveCallback = result.remaining > 0
|
||||||
? () => showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id)
|
? () => showMoveAfterUseModal(currentProduct, usedFrom, result.remaining, result.opened_id)
|
||||||
: () => showPage('dashboard');
|
: () => showPage('dashboard');
|
||||||
@@ -6568,9 +6705,19 @@ function _markBringPurchased(names) {
|
|||||||
localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
|
localStorage.setItem('_bringPurchasedBlocklist', JSON.stringify(map));
|
||||||
}
|
}
|
||||||
|
|
||||||
function _isBringPurchased(name) {
|
function _isBringPurchased(name, urgency) {
|
||||||
|
// Critical items: blocked only 30 min (enough to put groceries away).
|
||||||
|
// High: 90 min. Others: full 4 h.
|
||||||
|
const ttl = urgency === 'critical' ? 30 * 60 * 1000
|
||||||
|
: urgency === 'high' ? 90 * 60 * 1000
|
||||||
|
: _BRING_PURCHASED_TTL;
|
||||||
const map = _getBringPurchasedBlocklist();
|
const map = _getBringPurchasedBlocklist();
|
||||||
return Object.keys(map).some(k => _nameTokens(name)[0] === _nameTokens(k)[0] || k === name.toLowerCase());
|
const now = Date.now();
|
||||||
|
return Object.keys(map).some(k => {
|
||||||
|
const matches = _nameTokens(name)[0] === _nameTokens(k)[0] || k === name.toLowerCase();
|
||||||
|
if (!matches) return false;
|
||||||
|
return (now - map[k]) < ttl;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function autoAddCriticalItems() {
|
async function autoAddCriticalItems() {
|
||||||
@@ -6578,9 +6725,13 @@ async function autoAddCriticalItems() {
|
|||||||
const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0');
|
const lastRun = parseInt(localStorage.getItem('_autoAddedCriticalTs') || '0');
|
||||||
if (Date.now() - lastRun < 10 * 60 * 1000) return;
|
if (Date.now() - lastRun < 10 * 60 * 1000) return;
|
||||||
localStorage.setItem('_autoAddedCriticalTs', String(Date.now()));
|
localStorage.setItem('_autoAddedCriticalTs', String(Date.now()));
|
||||||
const critical = smartShoppingItems.filter(i => i.urgency === 'critical' && !i.on_bring && !_isBringPurchased(i.name));
|
// Auto-add: critical urgency (always) + high urgency that are completely out of stock (qty=0)
|
||||||
if (critical.length === 0) return;
|
const toAdd = smartShoppingItems.filter(i =>
|
||||||
const itemsToAdd = critical.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) }));
|
!i.on_bring && !_isBringPurchased(i.name, i.urgency) &&
|
||||||
|
(i.urgency === 'critical' || (i.urgency === 'high' && i.current_qty === 0))
|
||||||
|
);
|
||||||
|
if (toAdd.length === 0) return;
|
||||||
|
const itemsToAdd = toAdd.map(i => ({ name: i.name, specification: _urgencyToSpec(i.urgency, i.brand) }));
|
||||||
try {
|
try {
|
||||||
const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID });
|
const result = await api('bring_add', {}, 'POST', { items: itemsToAdd, listUUID: shoppingListUUID });
|
||||||
if (result.success && result.added > 0) {
|
if (result.success && result.added > 0) {
|
||||||
@@ -6591,6 +6742,25 @@ async function autoAddCriticalItems() {
|
|||||||
} catch (e) { /* ignore */ }
|
} catch (e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manually force a full Bring! sync: clears the purchased blocklist and all
|
||||||
|
* auto-add/cleanup timers, then re-adds all urgent items from scratch.
|
||||||
|
* Triggered by the user pressing "Forza sincronizzazione Bring!".
|
||||||
|
*/
|
||||||
|
async function forceSyncBring() {
|
||||||
|
const btn = document.getElementById('btn-force-sync');
|
||||||
|
if (btn) { btn.disabled = true; btn.textContent = '⏳ Sincronizzazione…'; }
|
||||||
|
// Clear all guards so the next run is unconditional
|
||||||
|
localStorage.removeItem('_bringPurchasedBlocklist');
|
||||||
|
localStorage.removeItem('_autoAddedCriticalTs');
|
||||||
|
localStorage.removeItem('_bringCleanupTs');
|
||||||
|
logOperation('force_sync_bring', {});
|
||||||
|
// Reload everything from scratch
|
||||||
|
await loadShoppingList();
|
||||||
|
if (btn) { btn.disabled = false; btn.textContent = '🔄 Forza sincronizzazione Bring!'; }
|
||||||
|
showToast('🔄 Sincronizzazione completata', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One-time cleanup: remove items from Bring! that were auto-added but the algorithm no
|
* One-time cleanup: remove items from Bring! that were auto-added but the algorithm no
|
||||||
* longer considers relevant. CONSERVATIVE: only removes items that match a known product
|
* longer considers relevant. CONSERVATIVE: only removes items that match a known product
|
||||||
@@ -7934,21 +8104,30 @@ async function loadLog(more = false) {
|
|||||||
colorClass = 'log-in';
|
colorClass = 'log-in';
|
||||||
} else {
|
} else {
|
||||||
icon = '➖';
|
icon = '➖';
|
||||||
typeLabel = 'Usato';
|
typeLabel = t.type === 'waste' ? 'Buttato' : 'Usato';
|
||||||
colorClass = 'log-out';
|
colorClass = 'log-out';
|
||||||
}
|
}
|
||||||
const brand = t.brand ? ` <em>(${t.brand})</em>` : '';
|
const brand = t.brand ? ` <em>(${t.brand})</em>` : '';
|
||||||
const loc = t.location || '';
|
const loc = t.location || '';
|
||||||
const locLabels = { 'frigo': '🧊 Frigo', 'freezer': '❄️ Freezer', 'dispensa': '🗄️ Dispensa' };
|
const locLabels = { 'frigo': '🧊 Frigo', 'freezer': '❄️ Freezer', 'dispensa': '🗄️ Dispensa' };
|
||||||
const locStr = t.type === 'bring' ? '' : (locLabels[loc] || ('📍 ' + loc));
|
const locStr = t.type === 'bring' ? '' : (locLabels[loc] || ('📍 ' + loc));
|
||||||
const notes = t.notes ? ` · ${t.notes}` : '';
|
const isAnnotation = (t.notes || '').includes('[Annullato]');
|
||||||
|
const notes = t.notes && !isAnnotation ? ` · ${t.notes}` : '';
|
||||||
|
const undone = t.undone == 1 || isAnnotation;
|
||||||
|
|
||||||
html += `<div class="log-entry ${colorClass}">`;
|
// Can undo if within 24h, not already undone, not a bring entry, not a counter-transaction
|
||||||
|
const ageMs = Date.now() - new Date(t.created_at + 'Z').getTime();
|
||||||
|
const canUndo = !undone && t.type !== 'bring' && ageMs < 86400000;
|
||||||
|
|
||||||
|
html += `<div class="log-entry ${colorClass}${undone ? ' log-undone' : ''}" id="log-entry-${t.id}">`;
|
||||||
html += `<span class="log-icon">${icon}</span>`;
|
html += `<span class="log-icon">${icon}</span>`;
|
||||||
html += `<div class="log-info">`;
|
html += `<div class="log-info">`;
|
||||||
html += `<div class="log-product"><strong>${t.name}</strong>${brand}</div>`;
|
html += `<div class="log-product"><strong>${escapeHtml(t.name)}</strong>${brand}${undone ? ' <span class="log-undone-badge">Annullato</span>' : ''}</div>`;
|
||||||
html += `<div class="log-detail">${typeLabel} ${t.type !== 'bring' ? t.quantity + ' ' + (t.unit || '') + ' · ' : ''}${locStr}${notes} · ${timeStr}</div>`;
|
html += `<div class="log-detail">${typeLabel} ${t.type !== 'bring' ? (t.quantity + ' ' + (t.unit || '')) + ' · ' : ''}${locStr}${notes} · ${timeStr}</div>`;
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
|
if (canUndo) {
|
||||||
|
html += `<button class="btn-log-undo" onclick="undoTransactionEntry(${t.id}, '${escapeHtml(t.type)}', '${escapeHtml(t.name || '')}')" title="Annulla questa operazione">↩</button>`;
|
||||||
|
}
|
||||||
html += `</div>`;
|
html += `</div>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -7968,6 +8147,36 @@ async function loadLog(more = false) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function undoTransactionEntry(id, type, name) {
|
||||||
|
const action = type === 'in' ? 'rimozione di' : 'ripristino di';
|
||||||
|
if (!confirm(`Annullare questa operazione?\n→ ${action} ${name}`)) return;
|
||||||
|
try {
|
||||||
|
const res = await api('transaction_undo', {}, 'POST', { id });
|
||||||
|
if (res.success) {
|
||||||
|
showToast(`↩ Operazione annullata per ${res.name || name}`, 'success');
|
||||||
|
// Mark the entry visually without reloading all
|
||||||
|
const el = document.getElementById(`log-entry-${id}`);
|
||||||
|
if (el) {
|
||||||
|
el.classList.add('log-undone');
|
||||||
|
const undoBtn = el.querySelector('.btn-log-undo');
|
||||||
|
if (undoBtn) undoBtn.remove();
|
||||||
|
const nameEl = el.querySelector('.log-product strong');
|
||||||
|
if (nameEl && !el.querySelector('.log-undone-badge')) {
|
||||||
|
nameEl.insertAdjacentHTML('afterend', ' <span class="log-undone-badge">Annullato</span>');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (res.already_undone) {
|
||||||
|
showToast('Operazione già annullata', 'info');
|
||||||
|
} else if (res.too_old) {
|
||||||
|
showToast('Non è possibile annullare operazioni più vecchie di 24 ore', 'error');
|
||||||
|
} else {
|
||||||
|
showToast(res.error || 'Errore durante l\'annullamento', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Errore di connessione', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===== WEEKLY MEAL PLAN =====
|
// ===== WEEKLY MEAL PLAN =====
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -8133,6 +8342,23 @@ const MEAL_TYPES = [
|
|||||||
{ id: 'succo', icon: '🧃', label: 'Succo di Frutta', from: -1, to: -1 },
|
{ id: 'succo', icon: '🧃', label: 'Succo di Frutta', from: -1, to: -1 },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const MEAL_SUB_TYPES = {
|
||||||
|
dolce: [
|
||||||
|
{ id: 'torta', icon: '🎂', label: 'Torta' },
|
||||||
|
{ id: 'crema', icon: '🍮', label: 'Crema / Budino' },
|
||||||
|
{ id: 'crumble', icon: '🥧', label: 'Crumble / Crostata' },
|
||||||
|
{ id: 'biscotti', icon: '🍪', label: 'Biscotti / Pasticcini' },
|
||||||
|
{ id: 'frutta', icon: '🍓', label: 'Dolce alla Frutta' },
|
||||||
|
],
|
||||||
|
succo: [
|
||||||
|
{ id: 'dolce', icon: '🍑', label: 'Dolce / Fruttato' },
|
||||||
|
{ id: 'energizzante', icon: '⚡', label: 'Energizzante' },
|
||||||
|
{ id: 'detox', icon: '🥬', label: 'Detox / Verde' },
|
||||||
|
{ id: 'rinfrescante', icon: '🧊', label: 'Rinfrescante' },
|
||||||
|
{ id: 'vitaminico', icon: '🍊', label: 'Vitaminico / Agrumi' },
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
function getMealType() {
|
function getMealType() {
|
||||||
const hour = new Date().getHours();
|
const hour = new Date().getHours();
|
||||||
for (const m of MEAL_TYPES) {
|
for (const m of MEAL_TYPES) {
|
||||||
@@ -8326,11 +8552,11 @@ let _recipeUseContext = null; // { idx, productId, btn, qtyNumber }
|
|||||||
let _recipeUseConfMode = null;
|
let _recipeUseConfMode = null;
|
||||||
let _recipeUseNormalUnit = 'pz';
|
let _recipeUseNormalUnit = 'pz';
|
||||||
|
|
||||||
async function useRecipeIngredient(idx, productId, location, qtyNumber, btn) {
|
async function useRecipeIngredient(idx, productId, location, qtyNumber, btn, recipeQty) {
|
||||||
if (btn.disabled) return;
|
if (btn.disabled) return;
|
||||||
if (!qtyNumber || qtyNumber <= 0) qtyNumber = 1;
|
if (!qtyNumber || qtyNumber <= 0) qtyNumber = 1;
|
||||||
|
|
||||||
_recipeUseContext = { idx, productId, btn, qtyNumber };
|
_recipeUseContext = { idx, productId, btn, qtyNumber, recipeQty };
|
||||||
_recipeUseConfMode = null;
|
_recipeUseConfMode = null;
|
||||||
|
|
||||||
// Fetch inventory to build the modal
|
// Fetch inventory to build the modal
|
||||||
@@ -8410,20 +8636,40 @@ async function useRecipeIngredient(idx, productId, location, qtyNumber, btn) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Available info
|
// Scale live UI: show only when scale is connected and unit is g or ml
|
||||||
const availInfo = items.map(i => {
|
const availInfo = items.map(i => {
|
||||||
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
|
const loc = LOCATIONS[i.location] || { icon: '📦', label: i.location };
|
||||||
return `${loc.icon} ${formatQuantity(i.quantity, i.unit, i.default_quantity, i.package_unit)}`;
|
return `${loc.icon} ${formatQuantity(i.quantity, i.unit, i.default_quantity, i.package_unit)}`;
|
||||||
}).join(' · ');
|
}).join(' · ');
|
||||||
|
|
||||||
|
const showScaleLive = _scaleConnected && (unit === 'g' || unit === 'ml' ||
|
||||||
|
(_recipeUseConfMode && ((_recipeUseConfMode.packageUnit || '').toLowerCase() === 'g' || (_recipeUseConfMode.packageUnit || '').toLowerCase() === 'ml')));
|
||||||
|
const scaleLiveSection = showScaleLive ? `
|
||||||
|
<div id="ruse-scale-live-box" class="scale-live-box" style="flex-direction:column;align-items:stretch;border-color:var(--color-accent,#7c3aed)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:4px">
|
||||||
|
<span class="scale-live-icon">⚖️</span>
|
||||||
|
<span id="ruse-scale-live-val" class="scale-live-val" style="color:var(--color-accent,#7c3aed)">— —</span>
|
||||||
|
<span id="ruse-scale-live-status" style="font-size:0.75rem;color:var(--text-muted);margin-left:auto"></span>
|
||||||
|
</div>
|
||||||
|
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;margin-bottom:4px">
|
||||||
|
<div id="ruse-scale-progress-bar" style="height:100%;width:0%;background:var(--color-accent,#7c3aed);transition:none;border-radius:2px"></div>
|
||||||
|
</div>
|
||||||
|
<div style="height:4px;background:var(--border);border-radius:2px;overflow:hidden;display:none" id="ruse-scale-confirm-wrap">
|
||||||
|
<div id="ruse-scale-confirm-bar" style="height:100%;width:100%;background:#22c55e;transition:none;border-radius:2px"></div>
|
||||||
|
</div>
|
||||||
|
<div id="ruse-scale-live-label" class="scale-live-label" style="margin-top:3px">Attendi 10s di stabilità per la compilazione automatica…</div>
|
||||||
|
</div>` : '';
|
||||||
|
|
||||||
document.getElementById('modal-content').innerHTML = `
|
document.getElementById('modal-content').innerHTML = `
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h3>📤 Usa ingrediente</h3>
|
<h3>📤 Usa ingrediente</h3>
|
||||||
<button class="modal-close" onclick="closeModal()">✕</button>
|
<button class="modal-close" onclick="closeModal()">✕</button>
|
||||||
</div>
|
</div>
|
||||||
<div style="padding:0 16px 16px">
|
<div style="padding:0 16px 16px">
|
||||||
<p style="margin-bottom:8px;font-weight:600">${escapeHtml(items[0].name)}</p>
|
<p style="margin-bottom:4px;font-weight:600">${escapeHtml(items[0].name)}</p>
|
||||||
|
${recipeQty ? `<p style="margin-bottom:8px;background:var(--bg-elevated,rgba(124,58,237,0.12));border-left:3px solid var(--color-accent,#7c3aed);border-radius:6px;padding:6px 10px;font-size:0.9rem">📋 Ricetta: <strong>${escapeHtml(recipeQty)}</strong></p>` : ''}
|
||||||
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:12px">📦 ${availInfo}</p>
|
<p style="font-size:0.82rem;color:var(--text-muted);margin-bottom:12px">📦 ${availInfo}</p>
|
||||||
|
${scaleLiveSection}
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>📍 Da dove?</label>
|
<label>📍 Da dove?</label>
|
||||||
<div class="location-selector">${locButtons}</div>
|
<div class="location-selector">${locButtons}</div>
|
||||||
@@ -8676,7 +8922,7 @@ function renderRecipe(r) {
|
|||||||
if (alreadyUsed) {
|
if (alreadyUsed) {
|
||||||
html += `<button class="btn-use-ingredient btn-used" disabled>✔️ Scalato</button>`;
|
html += `<button class="btn-use-ingredient btn-used" disabled>✔️ Scalato</button>`;
|
||||||
} else {
|
} else {
|
||||||
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this)" title="Scala dalla dispensa">📦 Usa</button>`;
|
html += `<button class="btn-use-ingredient" onclick="useRecipeIngredient(${idx}, ${ing.product_id}, '${loc}', ${qtyNum}, this, '${(ing.qty || '').replace(/'/g, "'")}')" title="Scala dalla dispensa">📦 Usa</button>`;
|
||||||
}
|
}
|
||||||
html += `</li>`;
|
html += `</li>`;
|
||||||
} else {
|
} else {
|
||||||
@@ -9245,6 +9491,27 @@ function updateRecipeMealTitle() {
|
|||||||
const meal = getSelectedMealType();
|
const meal = getSelectedMealType();
|
||||||
document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || '🍳 Ricetta';
|
document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || '🍳 Ricetta';
|
||||||
_renderMealPlanHint(meal);
|
_renderMealPlanHint(meal);
|
||||||
|
_renderMealSubTypes(meal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderMealSubTypes(mealId) {
|
||||||
|
const container = document.getElementById('recipe-subtype-group');
|
||||||
|
if (!container) return;
|
||||||
|
const subs = MEAL_SUB_TYPES[mealId];
|
||||||
|
if (!subs) {
|
||||||
|
container.style.display = 'none';
|
||||||
|
container.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
container.style.display = '';
|
||||||
|
container.innerHTML = subs.map((s, i) =>
|
||||||
|
`<label class="recipe-meal-chip recipe-subtype-chip"><input type="radio" name="recipe-subtype" value="${s.id}"${i === 0 ? ' checked' : ''}> ${s.icon} ${s.label}</label>`
|
||||||
|
).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSelectedSubType() {
|
||||||
|
const checked = document.querySelector('input[name="recipe-subtype"]:checked');
|
||||||
|
return checked ? checked.value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Show/hide the meal-plan badge hint + top banner in the recipe dialog. */
|
/** Show/hide the meal-plan badge hint + top banner in the recipe dialog. */
|
||||||
@@ -9354,6 +9621,7 @@ async function generateRecipe() {
|
|||||||
const result = await api('generate_recipe', {}, 'POST', {
|
const result = await api('generate_recipe', {}, 'POST', {
|
||||||
meal,
|
meal,
|
||||||
persons,
|
persons,
|
||||||
|
sub_type: MEAL_SUB_TYPES[meal] ? getSelectedSubType() : '',
|
||||||
options,
|
options,
|
||||||
appliances: settings.appliances || [],
|
appliances: settings.appliances || [],
|
||||||
dietary_restrictions: settings.dietary_restrictions || '',
|
dietary_restrictions: settings.dietary_restrictions || '',
|
||||||
@@ -10505,7 +10773,7 @@ async function _backgroundBringSync() {
|
|||||||
|
|
||||||
if (!bringMatch) {
|
if (!bringMatch) {
|
||||||
// Not on Bring — add if critical AND not recently purchased
|
// Not on Bring — add if critical AND not recently purchased
|
||||||
if (si.urgency === 'critical' && !_isBringPurchased(si.name)) {
|
if (si.urgency === 'critical' && !_isBringPurchased(si.name, 'critical')) {
|
||||||
toAdd.push({ name: si.name, specification: expectedSpec });
|
toAdd.push({ name: si.name, specification: expectedSpec });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -11,11 +11,25 @@ android {
|
|||||||
applicationId = "it.dadaloop.evershelf.kiosk"
|
applicationId = "it.dadaloop.evershelf.kiosk"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 3
|
versionCode = 4
|
||||||
versionName = "1.2.0"
|
versionName = "1.3.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
signingConfigs {
|
||||||
|
// Use the standard Android debug keystore so every machine produces
|
||||||
|
// APKs with the same debug signature — required for over-the-air updates.
|
||||||
|
getByName("debug") {
|
||||||
|
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
|
||||||
|
storePassword = "android"
|
||||||
|
keyAlias = "androiddebugkey"
|
||||||
|
keyPassword = "android"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
|
debug {
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = false
|
isMinifyEnabled = false
|
||||||
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"))
|
||||||
|
|||||||
@@ -124,10 +124,7 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
scaleStatusText = findViewById(R.id.scaleStatusText)
|
scaleStatusText = findViewById(R.id.scaleStatusText)
|
||||||
scaleStatusDetail = findViewById(R.id.scaleStatusDetail)
|
scaleStatusDetail = findViewById(R.id.scaleStatusDetail)
|
||||||
|
|
||||||
// Triple-tap on wizard title to exit kiosk
|
// Triple-tap on wizard title is disabled — exit only via the X button in the overlay
|
||||||
findViewById<TextView>(R.id.wizardTitle).setOnClickListener {
|
|
||||||
handleTripleTap()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Step 1
|
// Step 1
|
||||||
findViewById<MaterialButton>(R.id.btnGetStarted).setOnClickListener {
|
findViewById<MaterialButton>(R.id.btnGetStarted).setOnClickListener {
|
||||||
@@ -163,9 +160,9 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
finishWizard()
|
finishWizard()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Settings — triple-tap to exit
|
// Settings gear — short press opens settings, no kiosk exit via tap
|
||||||
btnSettings.setOnClickListener {
|
btnSettings.setOnClickListener {
|
||||||
handleTripleTap()
|
startActivity(Intent(this, SettingsActivity::class.java))
|
||||||
}
|
}
|
||||||
btnSettings.setOnLongClickListener {
|
btnSettings.setOnLongClickListener {
|
||||||
startActivity(Intent(this, SettingsActivity::class.java))
|
startActivity(Intent(this, SettingsActivity::class.java))
|
||||||
@@ -456,7 +453,8 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
override fun onPageFinished(view: WebView?, url: String?) {
|
override fun onPageFinished(view: WebView?, url: String?) {
|
||||||
super.onPageFinished(view, url)
|
super.onPageFinished(view, url)
|
||||||
// Kiosk overlay removed — exit is handled via the Android settings gear button
|
// Inject X (exit) and ↻ (refresh) buttons into the page header
|
||||||
|
injectKioskOverlay()
|
||||||
// Check for updates periodically
|
// Check for updates periodically
|
||||||
checkForUpdates()
|
checkForUpdates()
|
||||||
}
|
}
|
||||||
@@ -537,23 +535,21 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
// ── Inject kiosk buttons in header (left of title) ──────────────────
|
// ── Inject kiosk buttons in header (left of title) ──────────────────
|
||||||
|
|
||||||
private fun injectKioskOverlay() {
|
private fun injectKioskOverlay() {
|
||||||
|
// Use a position:fixed overlay so injection never depends on SPA DOM readiness.
|
||||||
val js = """
|
val js = """
|
||||||
(function() {
|
(function() {
|
||||||
if (document.getElementById('_kiosk_exit_btn')) return;
|
if (document.getElementById('_kiosk_overlay')) return;
|
||||||
var content = document.querySelector('.header-content');
|
|
||||||
var title = document.querySelector('.header-title');
|
|
||||||
if (!content || !title) return;
|
|
||||||
|
|
||||||
var wrap = document.createElement('div');
|
var wrap = document.createElement('div');
|
||||||
wrap.id = '_kiosk_controls';
|
wrap.id = '_kiosk_overlay';
|
||||||
wrap.style.cssText = 'display:flex;align-items:center;gap:6px;margin-right:8px;flex-shrink:0;';
|
wrap.style.cssText = 'position:fixed;top:8px;right:8px;z-index:2147483647;display:flex;gap:6px;align-items:center;pointer-events:auto;';
|
||||||
|
|
||||||
// Exit button
|
// Exit button
|
||||||
var exitBtn = document.createElement('button');
|
var exitBtn = document.createElement('button');
|
||||||
exitBtn.id = '_kiosk_exit_btn';
|
exitBtn.id = '_kiosk_exit_btn';
|
||||||
exitBtn.textContent = '\u2715';
|
exitBtn.textContent = '\u2715';
|
||||||
exitBtn.title = 'Esci dal kiosk';
|
exitBtn.title = 'Esci dal kiosk';
|
||||||
exitBtn.style.cssText = 'background:rgba(0,0,0,0.25);border:1.5px solid rgba(255,255,255,0.4);color:#fff;width:30px;height:30px;border-radius:50%;font-size:14px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;';
|
exitBtn.style.cssText = 'background:rgba(0,0,0,0.45);border:1.5px solid rgba(255,255,255,0.5);color:#fff;width:34px;height:34px;border-radius:50%;font-size:15px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;';
|
||||||
exitBtn.addEventListener('click', function(e) {
|
exitBtn.addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (confirm('Uscire dalla modalit\u00e0 kiosk?')) {
|
if (confirm('Uscire dalla modalit\u00e0 kiosk?')) {
|
||||||
@@ -566,7 +562,7 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
refBtn.id = '_kiosk_refresh_btn';
|
refBtn.id = '_kiosk_refresh_btn';
|
||||||
refBtn.textContent = '\u21bb';
|
refBtn.textContent = '\u21bb';
|
||||||
refBtn.title = 'Aggiorna pagina';
|
refBtn.title = 'Aggiorna pagina';
|
||||||
refBtn.style.cssText = 'background:rgba(0,0,0,0.25);border:1.5px solid rgba(255,255,255,0.4);color:#fff;width:30px;height:30px;border-radius:50%;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;';
|
refBtn.style.cssText = 'background:rgba(0,0,0,0.45);border:1.5px solid rgba(255,255,255,0.5);color:#fff;width:34px;height:34px;border-radius:50%;font-size:18px;cursor:pointer;display:flex;align-items:center;justify-content:center;-webkit-tap-highlight-color:transparent;touch-action:manipulation;';
|
||||||
refBtn.addEventListener('click', function(e) {
|
refBtn.addEventListener('click', function(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if (typeof _kioskBridge !== 'undefined') _kioskBridge.hardReload();
|
if (typeof _kioskBridge !== 'undefined') _kioskBridge.hardReload();
|
||||||
@@ -575,7 +571,7 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
wrap.appendChild(exitBtn);
|
wrap.appendChild(exitBtn);
|
||||||
wrap.appendChild(refBtn);
|
wrap.appendChild(refBtn);
|
||||||
content.insertBefore(wrap, title);
|
document.documentElement.appendChild(wrap);
|
||||||
})();
|
})();
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
webView.evaluateJavascript(js, null)
|
webView.evaluateJavascript(js, null)
|
||||||
@@ -644,9 +640,9 @@ class KioskActivity : AppCompatActivity() {
|
|||||||
var banner = document.createElement('div');
|
var banner = document.createElement('div');
|
||||||
banner.id = '_kiosk_update_banner';
|
banner.id = '_kiosk_update_banner';
|
||||||
banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#1e293b;color:#fbbf24;padding:10px 16px;font-size:13px;z-index:999998;display:flex;align-items:center;justify-content:space-between;border-top:2px solid #fbbf24;';
|
banner.style.cssText = 'position:fixed;bottom:0;left:0;right:0;background:#1e293b;color:#fbbf24;padding:10px 16px;font-size:13px;z-index:999998;display:flex;align-items:center;justify-content:space-between;border-top:2px solid #fbbf24;';
|
||||||
banner.innerHTML = '<span>⬆️ ${message.replace("\n", "<br>")}</span><button onclick="this.parentElement.remove()" style="background:none;border:none;color:#64748b;font-size:18px;cursor:pointer;">✕</button>';
|
banner.innerHTML = '<span>⬆️ ${message.replace("\n", "<br>")} — Per installare: disinstalla prima la versione attuale, poi installa la nuova.</span><button onclick="this.parentElement.remove()" style="background:none;border:none;color:#64748b;font-size:18px;cursor:pointer;">✕</button>';
|
||||||
document.body.appendChild(banner);
|
document.body.appendChild(banner);
|
||||||
setTimeout(function(){ var b = document.getElementById('_kiosk_update_banner'); if(b) b.remove(); }, 3000);
|
setTimeout(function(){ var b = document.getElementById('_kiosk_update_banner'); if(b) b.remove(); }, 12000);
|
||||||
})();
|
})();
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
webView.evaluateJavascript(js, null)
|
webView.evaluateJavascript(js, null)
|
||||||
|
|||||||
+14
-5
@@ -20,7 +20,7 @@
|
|||||||
<!-- Top Header -->
|
<!-- Top Header -->
|
||||||
<header class="app-header">
|
<header class="app-header">
|
||||||
<div class="header-content">
|
<div class="header-content">
|
||||||
<h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.4.0</span></h1>
|
<h1 class="header-title" onclick="showPage('dashboard')"><span data-i18n="nav.title">🏠 EverShelf</span><span class="header-version">v1.5.0</span></h1>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<span id="scale-status-indicator" class="scale-status-indicator scale-status-disconnected" style="display:none" data-i18n-title="scale.status_disconnected" title="⚖️ Bilancia">⚖️</span>
|
<span id="scale-status-indicator" class="scale-status-indicator scale-status-disconnected" style="display:none" data-i18n-title="scale.status_disconnected" title="⚖️ Bilancia">⚖️</span>
|
||||||
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title">
|
<button class="header-scan-btn header-gemini-btn" onclick="showPage('chat')" title="Chat con Gemini" data-i18n-title="chat.title">
|
||||||
@@ -603,6 +603,9 @@
|
|||||||
<button class="btn btn-large btn-accent" onclick="generateSuggestions()" id="btn-suggest">
|
<button class="btn btn-large btn-accent" onclick="generateSuggestions()" id="btn-suggest">
|
||||||
🤖 Suggerisci cosa comprare
|
🤖 Suggerisci cosa comprare
|
||||||
</button>
|
</button>
|
||||||
|
<button class="btn btn-secondary" onclick="forceSyncBring()" style="margin-top:4px">
|
||||||
|
🔄 Forza sincronizzazione Bring!
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -637,6 +640,11 @@
|
|||||||
🛒 Aggiungi selezionati a Bring!
|
🛒 Aggiungi selezionati a Bring!
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div style="text-align:center;margin-top:8px">
|
||||||
|
<button class="btn btn-secondary btn-sm" onclick="forceSyncBring()" id="btn-force-sync">
|
||||||
|
🔄 Forza sincronizzazione Bring!
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -670,10 +678,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- Log Page -->
|
<!-- Storico Page -->
|
||||||
<section id="page-log" class="page">
|
<section id="page-log" class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 data-i18n="log.title">📒 Log Operazioni</h2>
|
<h2 data-i18n="log.title">📋 Storico</h2>
|
||||||
</div>
|
</div>
|
||||||
<div id="log-list" class="log-list"></div>
|
<div id="log-list" class="log-list"></div>
|
||||||
<button class="btn btn-secondary full-width mt-2" id="log-load-more" style="display:none" onclick="loadLog(true)" data-i18n="btn.load_more">
|
<button class="btn btn-secondary full-width mt-2" id="log-load-more" style="display:none" onclick="loadLog(true)" data-i18n="btn.load_more">
|
||||||
@@ -1136,8 +1144,8 @@
|
|||||||
<span class="nav-label" data-i18n="nav.shopping">Spesa</span>
|
<span class="nav-label" data-i18n="nav.shopping">Spesa</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" onclick="showPage('log')" data-page="log">
|
<button class="nav-btn" onclick="showPage('log')" data-page="log">
|
||||||
<span class="nav-icon">📒</span>
|
<span class="nav-icon">�</span>
|
||||||
<span class="nav-label" data-i18n="nav.log">Log</span>
|
<span class="nav-label" data-i18n="nav.log">Storico</span>
|
||||||
</button>
|
</button>
|
||||||
<button class="nav-btn" onclick="showPage('settings')" data-page="settings">
|
<button class="nav-btn" onclick="showPage('settings')" data-page="settings">
|
||||||
<span class="nav-icon">⚙️</span>
|
<span class="nav-icon">⚙️</span>
|
||||||
@@ -1155,6 +1163,7 @@
|
|||||||
<div class="form-group" style="text-align:left">
|
<div class="form-group" style="text-align:left">
|
||||||
<label>🕐 Per quale pasto?</label>
|
<label>🕐 Per quale pasto?</label>
|
||||||
<div class="recipe-meal-grid" id="recipe-meal-grid" onchange="updateRecipeMealTitle()"></div>
|
<div class="recipe-meal-grid" id="recipe-meal-grid" onchange="updateRecipeMealTitle()"></div>
|
||||||
|
<div class="recipe-meal-grid recipe-subtype-grid" id="recipe-subtype-group" style="display:none"></div>
|
||||||
</div>
|
</div>
|
||||||
<div id="recipe-mealplan-hint" class="recipe-mealplan-hint" style="display:none"></div>
|
<div id="recipe-mealplan-hint" class="recipe-mealplan-hint" style="display:none"></div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"inventory": "Vorrat",
|
"inventory": "Vorrat",
|
||||||
"recipes": "Rezepte",
|
"recipes": "Rezepte",
|
||||||
"shopping": "Einkauf",
|
"shopping": "Einkauf",
|
||||||
"log": "Log"
|
"log": "Verlauf"
|
||||||
},
|
},
|
||||||
"btn": {
|
"btn": {
|
||||||
"back": "← Zurück",
|
"back": "← Zurück",
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
"fields_filled": "✅ Felder von KI ausgefüllt"
|
"fields_filled": "✅ Felder von KI ausgefüllt"
|
||||||
},
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"title": "📒 Operationslog"
|
"title": "� Verlauf"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"title": "Gemini Chef",
|
"title": "Gemini Chef",
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
"inventory": "Dispensa",
|
"inventory": "Dispensa",
|
||||||
"recipes": "Ricette",
|
"recipes": "Ricette",
|
||||||
"shopping": "Spesa",
|
"shopping": "Spesa",
|
||||||
"log": "Log"
|
"log": "Storico"
|
||||||
},
|
},
|
||||||
"btn": {
|
"btn": {
|
||||||
"back": "← Indietro",
|
"back": "← Indietro",
|
||||||
@@ -236,7 +236,7 @@
|
|||||||
"fields_filled": "✅ Campi compilati dall'AI"
|
"fields_filled": "✅ Campi compilati dall'AI"
|
||||||
},
|
},
|
||||||
"log": {
|
"log": {
|
||||||
"title": "📒 Log Operazioni"
|
"title": "� Storico"
|
||||||
},
|
},
|
||||||
"chat": {
|
"chat": {
|
||||||
"title": "Gemini Chef",
|
"title": "Gemini Chef",
|
||||||
|
|||||||
Reference in New Issue
Block a user