Files
hub/public/pantry-view.js
T

92 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use strict';
// ---- Pantry (Recipes > Pantry): what you keep in the kitchen, how many are left, and the level that counts as running low. A phone can scan a barcode to add one or use one;
// the first time a barcode is seen you say which item it is (so Green Giant corn and the store brand are both "Corn"). Low items go on the shopping list in a tap.
const paApi = (p, o) => jfetch('/api/pantry' + p, o), paOpt = o => ({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(o) });
const paAgo = t => { const m = Math.round((Date.now() - t) / 60000); return m < 1 ? 'just now' : m < 60 ? m + ' min ago' : m < 1440 ? Math.round(m / 60) + ' h ago' : new Date(t).toLocaleDateString(undefined, { weekday: 'short' }); };
const paRecent = r => r.length ? `<h2>Recently</h2><div class="list">${r.map(x => `<div class="row"><span class="grow"><b>${esc(x.who)}</b> ${x.delta < 0 ? 'took' : 'put back'} ${esc(x.item)}<small>${esc(paAgo(x.t))}</small></span></div>`).join('')}</div>` : '';
/** The simple way to use things: a big tile for each item, tap to take one (with Undo). Children see only this. */
async function viewPantryTake() {
let st; const v = $('#view'), kid = !!(ME && ME.kid && ME.kid.on);
try { st = await paApi('/'); } catch (e) { v.innerHTML = `<div class="wrap"><h1>Pantry</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/recipes')) return;
const draw = () => {
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>${kid ? 'Pantry' : 'Take one'}</h1><span class="grow"></span>${kid ? '' : `<a class="btn ghost" href="#/recipes/pantry">Manage</a>`}</div>
<p class="sub" style="margin-top:0">Tap what you are taking. It counts one off so the family knows when to buy more.</p>
<div class="paTiles">${st.items.map(x => `<button type="button" class="paTile${x.qty === 0 ? ' out' : ''}${x.isLow && x.qty ? ' low' : ''}" data-u="${esc(x.id)}"${x.qty === 0 ? ' disabled' : ''} aria-label="Take one ${esc(x.name)}${x.qty != null ? ', ' + x.qty + ' left' : ''}"><b>${esc(x.name)}</b><small>${x.qty == null ? '' : x.qty === 0 ? 'none left' : x.qty + ' left'}</small></button>`).join('') || '<p class="empty">Nothing in the pantry yet.</p>'}</div>
<div class="bar" style="justify-content:flex-start"><button class="btn ghost" id="pasuse">${icon('plus')}Scan a barcode instead</button></div>${paRecent(st.recent || [])}</div>`;
v.querySelectorAll('[data-u]').forEach(b => b.onclick = async () => { b.disabled = true; try { const r = await paApi('/use', paOpt({ id: b.dataset.u })); st.items = r.items; st.recent = r.recent; draw(); const it = r.item;
toast(`Took one ${it.name}${it.qty != null ? ': ' + it.qty + ' left' : ''}`, { label: 'Undo', run: async () => { try { const u = await paApi('/use', paOpt({ id: it.id, undo: true })); st.items = u.items; st.recent = u.recent; if ($('.paTiles')) draw(); toast('Put back'); } catch (e) { toast(e.message); } } }); } catch (e) { b.disabled = false; toast(e.message); } });
$('#pasuse').onclick = () => pantryScan('use', list => { st.items = list; draw(); }, () => st.items);
};
draw();
}
async function viewPantry(sub) {
if (sub === 'take' || (ME && ME.kid && ME.kid.on)) return viewPantryTake();
let items = [], recent = []; const v = $('#view');
try { const r = await paApi('/'); items = r.items; recent = r.recent || []; } catch (e) { v.innerHTML = `<div class="wrap"><h1>Pantry</h1><p class="err">${esc(e.message)}</p></div>`; return; }
if (!location.hash.startsWith('#/recipes/pantry')) return;
const draw = () => {
v.innerHTML = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${typeof rcTabs === 'function' ? rcTabs('pantry') : ''}
<div class="bar" style="justify-content:flex-start"><a class="btn" href="#/recipes/pantry/take">Take one</a><button class="btn ghost" id="pasadd">${icon('plus')}Scan to add</button><button class="btn ghost" id="pasuse">Scan to use one</button></div>
<form id="paf" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pan" placeholder="Add an item" autocomplete="off" required style="flex:2;min-width:0"><input type="number" id="paq" placeholder="How many" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="How many are left"><input type="number" id="paw" placeholder="Low at" min="0" step="any" inputmode="decimal" style="flex:1;min-width:0" aria-label="Counts as running low at"><button class="btn">Add</button></form>
<div class="list">${items.map(x => `<div class="row"><span class="grow" style="white-space:normal"><b>${esc(x.name)}</b>${x.isLow ? ' <small style="color:var(--danger)">running low</small>' : ''}<small>${x.qty == null ? 'not counted' : x.qty + ' left'}${x.low != null ? ' · low at ' + x.low : ''}${x.codes.length ? ' · ' + x.codes.length + ' barcode' + (x.codes.length === 1 ? '' : 's') : ''}</small></span>
${x.isLow && typeof rcApi === 'function' ? `<button class="btn ghost" data-shop="${esc(x.id)}" style="min-height:36px">To shopping</button>` : ''}${x.codes.length ? `<button class="ibtn" data-bc="${esc(x.id)}" aria-label="Barcodes for ${esc(x.name)}" title="Barcodes">${icon('list')}</button>` : ''}${x.qty != null ? `<button class="ibtn" data-d="${esc(x.id)}:-1" aria-label="One less ${esc(x.name)}">−</button><button class="ibtn" data-d="${esc(x.id)}:1" aria-label="One more ${esc(x.name)}">+</button>` : ''}<button class="ibtn" data-x="${esc(x.id)}" aria-label="Remove ${esc(x.name)}">${icon('trash')}</button></div>`).join('') || '<div class="row"><span class="grow"><small>Nothing yet. Add what you keep in the kitchen, or scan a barcode.</small></span></div>'}</div>
<p class="sub" style="margin-top:12px">Counts are kept by hand (and by scanning); they do not go down when you cook. The meal plan points out ingredients you already have.</p>${paRecent(recent)}</div>`;
const go = async (p, o) => { try { items = (await paApi(p, paOpt(o))).items; draw(); } catch (e) { toast(e.message); } };
$('#paf').onsubmit = e => { e.preventDefault(); go('/item', { name: $('#pan').value, qty: $('#paq').value, low: $('#paw').value }); };
v.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const [id, d] = b.dataset.d.split(':'); go('/adjust', { id, delta: +d }); });
v.querySelectorAll('[data-x]').forEach(b => b.onclick = () => go('/delete', { id: b.dataset.x }));
v.querySelectorAll('[data-shop]').forEach(b => b.onclick = async () => { const it = items.find(x => x.id === b.dataset.shop); try { await rcApi('/shopping/add', rcOpt('POST', { text: it.name })); toast(it.name + ' added to the shopping list'); } catch (e) { toast(e.message); } });
v.querySelectorAll('[data-bc]').forEach(b => b.onclick = () => { const it = items.find(x => x.id === b.dataset.bc), el = openSheet('Barcodes for ' + it.name, 'Scanning any of these counts as ' + it.name + '.', `<div class="list">${it.codes.map(c => `<div class="row"><span class="grow"><b>${esc(c)}</b></span><button class="ibtn" data-u="${esc(c)}" aria-label="Unlink ${esc(c)}">${icon('trash')}</button></div>`).join('')}</div>`); el.querySelectorAll('[data-u]').forEach(u => u.onclick = async () => { closeSheet(); go('/unlink', { id: it.id, code: u.dataset.u }); }); });
$('#pasadd').onclick = () => pantryScan('add', list => { items = list; draw(); }, () => items); $('#pasuse').onclick = () => pantryScan('use', list => { items = list; draw(); }, () => items);
};
draw();
}
/** The scanner: the camera looks for barcodes and each one found adds one (or uses one). Keeps going so a bag of shopping can be done in a row. */
let paZx = null;
const paLoadZx = () => paZx || (paZx = new Promise((ok, no) => { if (window.ZXing) return ok(window.ZXing); const s = document.createElement('script'); s.src = '/vendor/zxing/zxing.min.js'; s.onload = () => ok(window.ZXing); s.onerror = () => { paZx = null; no(new Error('Could not load the scanner')); }; document.head.appendChild(s); }));
function pantryScan(mode, onChange, getItems) {
const el = openSheet(mode === 'use' ? 'Scan to use one' : 'Scan to add', mode === 'use' ? 'Each barcode takes one off the count.' : 'Each barcode adds one to the count.', `<video id="pscv" playsinline muted autoplay style="width:100%;border-radius:12px;background:#000;aspect-ratio:4/3;object-fit:cover"></video>
<p class="sub" id="pscs" role="status" style="margin:8px 0">Starting the camera…</p><div id="pscq"></div>
<form id="pscm" class="bar" style="flex-wrap:nowrap;gap:6px"><input type="text" id="pscn" inputmode="numeric" placeholder="Or type the barcode number" autocomplete="off" aria-label="Barcode number" style="flex:1;min-width:0"><button class="btn ghost">Go</button></form>`);
const video = el.querySelector('#pscv'), status = t => { const s = el.querySelector('#pscs'); if (s) s.textContent = t; };
let stream = null, stopped = false, busy = false, last = '', lastAt = 0, reader = null, timer = 0;
const stop = () => { stopped = true; clearInterval(timer); try { reader && reader.reset(); } catch { /* not started */ } if (stream) stream.getTracks().forEach(t => t.stop()); };
const alive = () => !stopped && el.isConnected;
const resume = () => { busy = false; if (alive()) { el.querySelector('#pscq').innerHTML = ''; status('Point the camera at a barcode'); } };
const finish = (r, said) => { onChange(r.items); status(said); toast(said); try { navigator.vibrate && navigator.vibrate(60); } catch { /* no vibration */ } setTimeout(resume, 1300); };
const found = async code => {
if (busy || !alive() || (code === last && Date.now() - lastAt < 4000)) return; busy = true; last = code; lastAt = Date.now(); status('Looking up ' + code + '…');
try {
const r = await paApi('/lookup?code=' + code); if (!alive()) return;
if (r.linked) { const o = await paApi('/scan', paOpt({ code, mode })); return finish(o, `${o.item.name}: ${o.item.qty} left${o.item.isLow ? ' (running low)' : ''}`); }
const list = (getItems() || []), matches = (r.matches || []).map(m => m.id), sorted = [...list.filter(x => matches.includes(x.id)), ...list.filter(x => !matches.includes(x.id))];
el.querySelector('#pscq').innerHTML = `<form id="pscf"><p style="margin:0 0 6px"><b>${esc(r.product ? r.product.name : 'Barcode ' + code)}</b>${r.product && r.product.brand ? ' <small>· ' + esc(r.product.brand) + '</small>' : ''}</p>
${!r.product ? `<p class="sub" style="margin:0 0 6px">${r.lookedUp ? 'The product database does not know this one.' : 'Could not reach the product database.'} Say what it is once and it is remembered.</p>` : '<p class="sub" style="margin:0 0 6px">First time this barcode is seen. Which item is it?</p>'}
<label for="pscw">Which item</label><select id="pscw">${sorted.map(x => `<option value="${esc(x.id)}">${esc(x.name)}</option>`).join('')}<option value=""${matches.length ? '' : ' selected'}>A new item…</option></select>
<div id="pscnw"${matches.length ? ' hidden' : ''}><label for="pscnn">Name of the new item</label><input type="text" id="pscnn" value="${esc(r.product ? r.product.short : '')}" autocomplete="off" maxlength="60"></div>
<div class="bar"><button type="button" class="btn ghost" id="pscx" style="flex:1;justify-content:center">Skip</button><button class="btn" style="flex:1;justify-content:center">${mode === 'use' ? 'Use one' : 'Add one'}</button></div><div class="err" id="psce"></div></form>`;
const sel = el.querySelector('#pscw'), nw = el.querySelector('#pscnw'); sel.onchange = () => { nw.hidden = !!sel.value; }; if (sel.value === '' ) nw.hidden = false; status('Which item is this?');
el.querySelector('#pscx').onclick = resume;
el.querySelector('#pscf').onsubmit = async e => { e.preventDefault(); try { const o = await paApi('/scan', paOpt({ code, mode, itemId: sel.value || undefined, name: sel.value ? undefined : el.querySelector('#pscnn').value })); finish(o, `${o.item.name}: ${o.item.qty} left${o.made ? ' (new item)' : ''}`); } catch (er) { el.querySelector('#psce').textContent = er.message; } };
} catch (e) { status(e.message); toast(e.message); setTimeout(resume, 1500); }
};
el.querySelector('#pscm').onsubmit = e => { e.preventDefault(); const c = el.querySelector('#pscn').value.replace(/\D/g, ''); el.querySelector('#pscn').value = ''; if (/^\d{8,14}$/.test(c)) { last = ''; found(c); } else status('A barcode is 8 to 14 digits.'); };
(async () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) return status('This browser cannot use the camera here (it needs a secure page). Type the number instead.');
try { stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: { ideal: 'environment' } }, audio: false }); } catch (e) { return status(e && e.name === 'NotAllowedError' ? 'The camera is blocked. Allow it for this site, or type the number instead.' : 'No camera could be started. Type the number instead.'); }
if (!alive()) return stop(); video.srcObject = stream; try { await video.play(); } catch { /* autoplay is allowed on a tap */ } status('Point the camera at a barcode');
if ('BarcodeDetector' in window) { // Chrome and Android have a built-in reader
let det; try { det = new BarcodeDetector({ formats: ['ean_13', 'ean_8', 'upc_a', 'upc_e'] }); } catch { det = null; }
if (det) { timer = setInterval(async () => { if (!alive()) return stop(); if (busy || video.readyState < 2) return; try { const r = await det.detect(video); if (r[0] && r[0].rawValue) found(r[0].rawValue); } catch { /* a frame that cannot be read */ } }, 300); return; }
}
try { // everything else (iPhone Safari): a reader written in JavaScript, loaded only now
const Z = await paLoadZx(), hints = new Map(); hints.set(Z.DecodeHintType.POSSIBLE_FORMATS, [Z.BarcodeFormat.EAN_13, Z.BarcodeFormat.EAN_8, Z.BarcodeFormat.UPC_A, Z.BarcodeFormat.UPC_E]); reader = new Z.BrowserMultiFormatReader(hints);
timer = setInterval(() => { if (!alive()) stop(); }, 500);
reader.decodeFromVideoElement(video, (res) => { if (res && alive() && !busy) found(res.getText()); });
} catch (e) { status(e.message + ' Type the number instead.'); }
})();
new MutationObserver((_, ob) => { if (!el.isConnected) { stop(); ob.disconnect(); } }).observe(document.body, { childList: true });
}