diff --git a/db.js b/db.js index b5ee9f9..4faedbc 100644 --- a/db.js +++ b/db.js @@ -266,6 +266,66 @@ function adjustQuantity(id, { newQuantity, delta, markOpened }) { return getItemById(id); } +function addPacket(id, count = 1) { + const item = getItemById(id); + if (!item) return null; + + const packSize = item.initial_quantity > 0 ? item.initial_quantity : 1; + const addedAmount = packSize * count; + const newQty = item.quantity + addedAmount; + + const stmt = db.prepare(` + UPDATE items SET + quantity = ?, + updated_at = datetime('now', 'localtime') + WHERE id = ? + `); + stmt.run(newQty, id); + + logActivity({ + item_id: id, + item_name: item.name, + action: 'add_packet', + amount_change: addedAmount, + unit: item.unit, + note: `Added ${count} packet(s) (+${addedAmount} ${item.unit})` + }); + + return getItemById(id); +} + +function removePacket(id, count = 1) { + const item = getItemById(id); + if (!item) return null; + + const packSize = item.initial_quantity > 0 ? item.initial_quantity : 1; + const removeAmount = packSize * count; + let newQty = item.quantity - removeAmount; + + if (newQty <= 0) { + newQty = 0; + } + + const stmt = db.prepare(` + UPDATE items SET + quantity = ?, + updated_at = datetime('now', 'localtime') + WHERE id = ? + `); + stmt.run(newQty, id); + + logActivity({ + item_id: id, + item_name: item.name, + action: 'remove_packet', + amount_change: -removeAmount, + unit: item.unit, + note: `Removed ${count} packet(s) (-${removeAmount} ${item.unit})` + }); + + return getItemById(id); +} + function deleteItem(id) { const item = getItemById(id); if (!item) return false; @@ -347,6 +407,8 @@ module.exports = { createItem, updateItem, adjustQuantity, + addPacket, + removePacket, deleteItem, getActivityLogs, getStats diff --git a/public/app.js b/public/app.js index 78d28a0..8c06154 100644 --- a/public/app.js +++ b/public/app.js @@ -1,12 +1,10 @@ /** * Fridger Frontend Application - * Handles dual view/manage modes, live barcode scanning, grams adjustment, and item management. + * Simplified UI: Fridge View (drawer-free) and 3 primary actions: Manage Grams, Add Pack, Remove Pack. */ -// Application State const state = { currentMode: 'view', // 'view' or 'manage' - viewGrouping: 'shelf', // 'shelf', 'category', 'grid' items: [], meta: { categories: [], @@ -16,8 +14,7 @@ const state = { filters: { search: '', category: 'All', - status: 'all', - location: 'All' + status: 'all' }, activeGramsItem: null, activeGramsValue: 0, @@ -77,7 +74,6 @@ async function loadItems() { const params = new URLSearchParams(); if (state.filters.search) params.append('search', state.filters.search); if (state.filters.category && state.filters.category !== 'All') params.append('category', state.filters.category); - if (state.filters.location && state.filters.location !== 'All') params.append('location', state.filters.location); if (state.filters.status && state.filters.status !== 'all') params.append('status', state.filters.status); const res = await fetch(`/api/items?${params.toString()}`); @@ -104,13 +100,13 @@ function switchMode(mode) { const manageContainer = document.getElementById('manage-mode-container'); if (mode === 'view') { - viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-teal-500 text-white shadow-md'; - manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-slate-300 hover:text-white'; + viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition bg-teal-500 text-white shadow-md'; + manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition text-slate-300 hover:text-white'; viewContainer.classList.remove('hidden'); manageContainer.classList.add('hidden'); } else { - manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 bg-teal-500 text-white shadow-md'; - viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition-all duration-200 text-slate-300 hover:text-white'; + manageBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition bg-teal-500 text-white shadow-md'; + viewBtn.className = 'flex items-center gap-2 px-3 sm:px-5 py-2 rounded-lg text-sm font-semibold transition text-slate-300 hover:text-white'; manageContainer.classList.remove('hidden'); viewContainer.classList.add('hidden'); } @@ -118,23 +114,6 @@ function switchMode(mode) { renderApp(); } -function setViewGrouping(grouping) { - state.viewGrouping = grouping; - - const btnShelf = document.getElementById('grp-shelf-btn'); - const btnCat = document.getElementById('grp-cat-btn'); - const btnGrid = document.getElementById('grp-grid-btn'); - - const activeClass = 'px-2.5 py-1 rounded bg-teal-500/20 text-teal-300 border border-teal-500/30 font-medium'; - const inactiveClass = 'px-2.5 py-1 rounded bg-slate-800 text-slate-400 border border-slate-700 font-medium'; - - btnShelf.className = grouping === 'shelf' ? activeClass : inactiveClass; - btnCat.className = grouping === 'category' ? activeClass : inactiveClass; - btnGrid.className = grouping === 'grid' ? activeClass : inactiveClass; - - renderFridgeView(); -} - let searchDebounceTimer = null; function handleSearch(val) { state.filters.search = val.trim(); @@ -148,7 +127,7 @@ function handleSearch(val) { clearTimeout(searchDebounceTimer); searchDebounceTimer = setTimeout(() => { loadItems(); - }, 250); + }, 200); } function clearSearch() { @@ -162,9 +141,9 @@ function setStatusFilter(status) { state.filters.status = status; document.querySelectorAll('[data-status-btn]').forEach(btn => { if (btn.getAttribute('data-status-btn') === status) { - btn.className = 'status-chip active px-3 py-2 rounded-lg bg-slate-800 border border-slate-700 text-white whitespace-nowrap transition font-semibold'; + btn.className = 'status-chip active px-3 py-1.5 rounded-lg bg-slate-800 border border-slate-700 text-white font-semibold'; } else { - btn.className = 'status-chip px-3 py-2 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200 whitespace-nowrap transition'; + btn.className = 'status-chip px-3 py-1.5 rounded-lg bg-slate-900 border border-slate-800 text-slate-400 hover:text-slate-200'; } }); loadItems(); @@ -181,7 +160,7 @@ function renderCategoryFilters() { if (!container) return; let html = ` - - - ${isWeight ? ` - - ` : ` - - `} - - - - - - `; -} - -function renderManageView() { - const container = document.getElementById('manage-items-list'); - if (!container) return; - - let html = ''; - - state.items.forEach(item => { + container.innerHTML = state.items.map(item => { const expiryInfo = getExpiryStatus(item.expiry_date); const percent = item.initial_quantity > 0 ? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100))) : 100; - const isWeight = item.unit === 'g' || item.unit === 'ml' || item.unit === 'kg' || item.unit === 'l'; + let progressBarColor = 'bg-teal-500'; + if (percent <= 20) progressBarColor = 'bg-rose-500'; + else if (percent <= 50) progressBarColor = 'bg-amber-500'; - html += ` -
+ return ` +
-
- - -
+
+ +
+ + ${escapeHtml(item.brand || item.category)} + + + + ${expiryInfo.label} + +
+ + +
${item.image_url ? ` - ${escapeHtml(item.name)} + ${escapeHtml(item.name)} ` : ` -
+
`} - -
-
-

${escapeHtml(item.name)}

- ${item.brand ? `${escapeHtml(item.brand)}` : ''} - ${item.is_opened ? `Opened` : ''} -
-
- 📍 ${escapeHtml(item.location || 'Fridge')} - • - ${expiryInfo.label} - ${item.barcode ? `•${escapeHtml(item.barcode)}` : ''} -
+ +
+

${escapeHtml(item.name)}

+ ${item.notes ? `

${escapeHtml(item.notes)}

` : ''}
- -
- - + +
+
+ + ${item.quantity} + ${item.unit} + + + Pack: ${item.initial_quantity} ${item.unit} (${percent}%) + +
+
+
+
-
- -
+ +
+ + + +
+ +
+ `; + }).join(''); +} + +// MODE 2: Manage Mode (Simplified list with the 3 main buttons) +function renderManageView() { + const container = document.getElementById('manage-items-list'); + if (!container) return; + + container.innerHTML = state.items.map(item => { + const expiryInfo = getExpiryStatus(item.expiry_date); + const percent = item.initial_quantity > 0 + ? Math.min(100, Math.max(0, Math.round((item.quantity / item.initial_quantity) * 100))) + : 100; + + return ` +
+ +
- -
-
-
Remaining:
-
- ${item.quantity} ${item.unit} (${percent}%) + +
+ ${item.image_url ? ` + ${escapeHtml(item.name)} + ` : ` +
+ +
+ `} + +
+
+

${escapeHtml(item.name)}

+ ${item.brand ? `${escapeHtml(item.brand)}` : ''} +
+
+ ${item.quantity} ${item.unit} + (Pack size: ${item.initial_quantity} ${item.unit}) + • + ${expiryInfo.label}
- -
- -
- ${isWeight ? ` - - - - ` : ` - - - `} - + +
+ + + + + + + + + + + + + +
`; - }); - - container.innerHTML = html; + }).join(''); } // ---------------------------------------------------- -// Grams & Quantity Adjustments +// 3 Primary Action Handlers: Add Pack, Remove Pack, Manage Grams +// ---------------------------------------------------- + +async function addWholePacket(itemId) { + try { + const res = await fetch(`/api/items/${itemId}/add-packet`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count: 1 }) + }); + + if (res.ok) { + const updated = await res.json(); + showToast(`Added 1 packet of ${updated.name} (Now: ${updated.quantity} ${updated.unit})`, 'success'); + await loadStats(); + await loadItems(); + } else { + showToast('Failed to add packet', 'error'); + } + } catch (err) { + console.error(err); + showToast('Network error', 'error'); + } +} + +async function removeWholePacket(itemId) { + try { + const res = await fetch(`/api/items/${itemId}/remove-packet`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ count: 1 }) + }); + + if (res.ok) { + const updated = await res.json(); + showToast(`Removed 1 packet of ${updated.name} (Remaining: ${updated.quantity} ${updated.unit})`, 'info'); + await loadStats(); + await loadItems(); + } else { + showToast('Failed to remove packet', 'error'); + } + } catch (err) { + console.error(err); + showToast('Network error', 'error'); + } +} + +// ---------------------------------------------------- +// Manage Grams Modal Logic // ---------------------------------------------------- function openGramsModal(itemId) { @@ -516,13 +441,13 @@ function openGramsModal(itemId) { state.activeGramsValue = item.quantity; document.getElementById('grams-modal-title').textContent = item.name; - document.getElementById('grams-modal-sub').textContent = `Package Size: ${item.initial_quantity} ${item.unit} • Location: ${item.location}`; + document.getElementById('grams-modal-sub').textContent = `1 Full Packet = ${item.initial_quantity} ${item.unit}`; document.getElementById('grams-modal-unit').textContent = item.unit; document.getElementById('grams-current-val').textContent = item.quantity; document.getElementById('grams-exact-input').value = item.quantity; const slider = document.getElementById('grams-slider'); - slider.max = item.initial_quantity || 1000; + slider.max = Math.max(item.initial_quantity * 2, item.quantity, 100); slider.step = (item.unit === 'g' || item.unit === 'ml') ? '5' : '1'; slider.value = item.quantity; @@ -570,7 +495,7 @@ function updateGramsGauge() { document.getElementById('grams-current-val').textContent = state.activeGramsValue; if (state.activeGramsItem && state.activeGramsItem.initial_quantity > 0) { const pct = Math.round((state.activeGramsValue / state.activeGramsItem.initial_quantity) * 100); - document.getElementById('grams-percent-text').textContent = `${pct}% remaining of original ${state.activeGramsItem.initial_quantity}${state.activeGramsItem.unit}`; + document.getElementById('grams-percent-text').textContent = `${pct}% remaining of a full ${state.activeGramsItem.initial_quantity}${state.activeGramsItem.unit} packet`; } } @@ -595,7 +520,7 @@ async function saveGramsAdjustment() { showToast('Failed to update quantity', 'error'); } } catch (err) { - console.error('Save grams error:', err); + console.error(err); showToast('Network error while updating quantity', 'error'); } } @@ -608,7 +533,7 @@ async function consumeAllAndRemove() { try { const res = await fetch(`/api/items/${itemId}`, { method: 'DELETE' }); if (res.ok) { - showToast('Item finished & removed', 'info'); + showToast('Item removed', 'info'); closeGramsModal(); await loadStats(); await loadItems(); @@ -619,44 +544,6 @@ async function consumeAllAndRemove() { } } -async function quickDelta(itemId, delta) { - try { - const res = await fetch(`/api/items/${itemId}/quantity`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ delta, markOpened: true }) - }); - - if (res.ok) { - const updated = await res.json(); - showToast(`${updated.name}: ${delta > 0 ? '+' : ''}${delta} ${updated.unit} (Now: ${updated.quantity} ${updated.unit})`, 'success'); - await loadStats(); - await loadItems(); - } - } catch (err) { - console.error(err); - showToast('Failed to adjust amount', 'error'); - } -} - -async function onManageSliderCommit(itemId, newQty) { - try { - const res = await fetch(`/api/items/${itemId}/quantity`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ newQuantity: parseFloat(newQty), markOpened: true }) - }); - - if (res.ok) { - showToast(`Updated quantity`, 'success'); - await loadStats(); - await loadItems(); - } - } catch (err) { - console.error(err); - } -} - // ---------------------------------------------------- // Item Add / Edit Modal // ---------------------------------------------------- @@ -672,7 +559,7 @@ function openItemModal(itemId = null) { if (!item) return; document.getElementById('modal-item-id').value = item.id; - document.getElementById('item-modal-title').textContent = 'Edit Fridge Item'; + document.getElementById('item-modal-title').textContent = 'Edit Item'; document.getElementById('modal-barcode').value = item.barcode || ''; document.getElementById('modal-name').value = item.name || ''; document.getElementById('modal-brand').value = item.brand || ''; @@ -680,9 +567,7 @@ function openItemModal(itemId = null) { document.getElementById('modal-initial-quantity').value = item.initial_quantity || item.quantity; document.getElementById('modal-unit').value = item.unit || 'g'; document.getElementById('modal-category').value = item.category || 'General'; - document.getElementById('modal-location').value = item.location || 'Fridge - Middle Shelf'; document.getElementById('modal-expiry').value = item.expiry_date || ''; - document.getElementById('modal-opened').checked = !!item.is_opened; document.getElementById('modal-notes').value = item.notes || ''; if (item.image_url) { @@ -693,11 +578,10 @@ function openItemModal(itemId = null) { } } else { document.getElementById('modal-item-id').value = ''; - document.getElementById('item-modal-title').textContent = 'Add New Fridge Item'; + document.getElementById('item-modal-title').textContent = 'Add Item'; document.getElementById('modal-unit').value = 'g'; document.getElementById('modal-quantity').value = ''; document.getElementById('modal-initial-quantity').value = ''; - document.getElementById('modal-location').value = 'Fridge - Middle Shelf'; } document.getElementById('item-modal').classList.remove('hidden'); @@ -746,7 +630,6 @@ function applyProductDataToForm(data) { } if (data.unit) document.getElementById('modal-unit').value = data.unit; if (data.category) document.getElementById('modal-category').value = data.category; - if (data.location) document.getElementById('modal-location').value = data.location; if (data.barcode) document.getElementById('modal-barcode').value = data.barcode; if (data.image_url) { @@ -777,9 +660,7 @@ async function handleItemFormSubmit(event) { initial_quantity: initQty, unit: document.getElementById('modal-unit').value, category: document.getElementById('modal-category').value, - location: document.getElementById('modal-location').value, expiry_date: document.getElementById('modal-expiry').value || null, - is_opened: document.getElementById('modal-opened').checked ? 1 : 0, notes: document.getElementById('modal-notes').value.trim(), image_url: document.getElementById('modal-image-url').value || null }; @@ -810,7 +691,7 @@ async function handleItemFormSubmit(event) { } async function confirmDeleteItem(itemId, name) { - if (confirm(`Are you sure you want to remove "${name}" from your fridge?`)) { + if (confirm(`Remove "${name}" from fridge?`)) { try { const res = await fetch(`/api/items/${itemId}`, { method: 'DELETE' }); if (res.ok) { @@ -858,7 +739,7 @@ async function startCameraScanner() { state.html5QrCode = new Html5Qrcode("barcode-reader"); const config = { fps: 10, - qrbox: { width: 250, height: 160 }, + qrbox: { width: 240, height: 150 }, aspectRatio: 1.333 }; @@ -870,7 +751,7 @@ async function startCameraScanner() { ); state.isScanning = true; - document.getElementById('scanner-status').textContent = 'Camera active. Point at barcode.'; + document.getElementById('scanner-status').textContent = 'Point camera at barcode.'; } catch (err) { console.warn('Camera start error:', err); document.getElementById('scanner-status').textContent = 'Camera unavailable. Please enter barcode number below.'; @@ -889,11 +770,9 @@ async function stopCameraScanner() { } async function onBarcodeScannedSuccess(decodedText) { - // Beep sound feedback playBeepSound(); - stopCameraScanner(); - document.getElementById('scanner-status').textContent = `Scanned ${decodedText}! Fetching info...`; + document.getElementById('scanner-status').textContent = `Scanned ${decodedText}!`; try { const res = await fetch(`/api/barcode/${encodeURIComponent(decodedText)}`); @@ -907,7 +786,7 @@ async function onBarcodeScannedSuccess(decodedText) { } else { openItemModal(); document.getElementById('modal-barcode').value = decodedText; - showToast(`Scanned barcode: ${decodedText}. Please enter details.`, 'info'); + showToast(`Scanned barcode: ${decodedText}. Enter details.`, 'info'); } } catch (err) { console.error(err); @@ -917,9 +796,7 @@ async function onBarcodeScannedSuccess(decodedText) { } } -function onBarcodeScanError(errorMessage) { - // Continuous scanning errors are expected until barcode is in frame -} +function onBarcodeScanError() {} function handleManualBarcodeSubmit() { const code = document.getElementById('scanner-manual-input').value.trim(); @@ -944,12 +821,12 @@ function playBeepSound() { } // ---------------------------------------------------- -// Utilities & Helpers +// Helpers // ---------------------------------------------------- function getExpiryStatus(expiryDateStr) { if (!expiryDateStr) { - return { label: 'No Expiry Set', badgeClass: 'bg-slate-800 text-slate-400', icon: 'calendar' }; + return { label: 'No Expiry', badgeClass: 'bg-slate-800 text-slate-400', icon: 'calendar' }; } const today = new Date(); @@ -1021,7 +898,7 @@ function showToast(message, type = 'info') { info: 'bg-slate-800 text-slate-100 border-slate-700' }; - toast.className = `p-3.5 rounded-xl border shadow-xl text-xs font-medium flex items-center gap-2 transform transition-all duration-300 translate-y-2 opacity-0 pointer-events-auto ${colors[type] || colors.info}`; + toast.className = `p-3 rounded-xl border shadow-xl text-xs font-medium flex items-center gap-2 transform transition-all duration-300 translate-y-2 opacity-0 pointer-events-auto ${colors[type] || colors.info}`; toast.innerHTML = `${escapeHtml(message)}`; container.appendChild(toast); @@ -1033,14 +910,14 @@ function showToast(message, type = 'info') { setTimeout(() => { toast.classList.add('translate-y-2', 'opacity-0'); setTimeout(() => toast.remove(), 300); - }, 3500); + }, 3000); } async function seedDemoData() { try { const res = await fetch('/api/seed?force=true', { method: 'POST' }); if (res.ok) { - showToast('Loaded sample fridge items!', 'success'); + showToast('Loaded sample items!', 'success'); await loadStats(); await loadItems(); } diff --git a/public/index.html b/public/index.html index 41679c2..358dc9f 100644 --- a/public/index.html +++ b/public/index.html @@ -1,9 +1,9 @@ - + - Fridger - Smart Fridge Inventory + Fridger - Smart Fridge Manager @@ -14,12 +14,9 @@ extend: { colors: { brand: { - 50: '#f0fdfa', - 100: '#ccfbf1', 400: '#2dd4bf', 500: '#14b8a6', 600: '#0d9488', - 700: '#0f766e', } } } @@ -38,42 +35,42 @@ -
-
-
+
+
+
- -
-
+ +
+
🧊
-

Fridger

- +

Fridger

+

Fridge Manager

- -
- -
- +
- -
@@ -81,56 +78,33 @@
- -
-
-
-
-
- -
-
-
0
-
Total In Fridge
-
+ +
+
+
+
+ + In Fridge: 0
- -
-
- -
-
-
0
-
Fresh & Good
-
+
+ + Fresh: 0
- -
-
- -
-
-
0
-
Expiring ≤ 3 Days
-
+
+ + Expiring Soon: 0
- -
-
- -
-
-
0
-
Opened / Partial
-
+
+ + Opened: 0
- -
-
+ +
+
@@ -138,9 +112,9 @@
-
- - - - - +
+ + + + +
-
+
-
+
- -
- -
-
- Showing all items -
-
- - - - -
-
- - -
- + +
+
+
- -
- -