'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 PA_UNITS = [['', 'each'], ['g', 'g'], ['kg', 'kg'], ['oz', 'oz'], ['lb', 'lb'], ['ml', 'ml'], ['l', 'litres'], ['tsp', 'tsp'], ['tbsp', 'tbsp'], ['cup', 'cups'], ['fl oz', 'fl oz'], ['pint', 'pints'], ['quart', 'quarts'], ['gallon', 'gallons']]; const paStep = u => ({ g: 100, ml: 100 }[u] || 1); 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 ? `
${esc(e.message)}
Tap what you are taking. It counts one off so the family knows when to buy more.
Nothing in the pantry yet.
'}${esc(e.message)}
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.
${paRecent(recent)}Starting the camera…
`); 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 = ``; 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 }); } /** Use some of an item: an amount in any unit that can be turned into the item's own (4 cups of the flour that is kept in pounds). */ function paUseSome(it, done) { const el = openSheet('Use some ' + it.name.toLowerCase(), `${it.label} left. Any unit works if it can be turned into ${it.unit || 'a count'} (cups of flour into pounds, for instance).`, ``); setTimeout(() => el.querySelector('#pusa').focus(), 80); el.querySelector('#pusf').onsubmit = async e => { e.preventDefault(); try { const r = await paApi('/take', paOpt({ takes: [{ id: it.id, amount: +el.querySelector('#pusa').value, unit: el.querySelector('#pusu').value }], note: 'used some' })); if (r.skipped.length) { el.querySelector('#puse').textContent = 'That cannot be turned into ' + (it.unit || 'a count') + ' for ' + it.name.toLowerCase() + '. Pick a matching unit.'; return; } closeSheet(); const d = r.done[0]; toast(`${d.name}: ${d.label || 'none'} left${d.isLow ? ' (running low)' : ''}`); done(r); } catch (er) { el.querySelector('#puse').textContent = er.message; } }; }