diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 42cebf1..c62eacd 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -34,6 +34,8 @@ jobs: - run: node tools/backup-test.js # backup restore (unsafe archives refused, old data kept) and personal export - run: node tools/listening-test.js # listening stats: real listening counted, seeks not, streak and finished books - run: node tools/moviefam-test.js # Movies & TV: favorites and the family movie night vote + - run: node tools/pantry-test.js # pantry: counts, low levels, barcodes (several per item), kids can only take + - run: node tools/units-test.js # units: reading recipe lines, converting, what a recipe takes out of the pantry - run: node tools/automations-test.js # automations: validation, triggers, daily cap, privacy - run: node tools/signal-test.js # Signal: linking, messages, sending, live updates diff --git a/docs/v2-roadmap.md b/docs/v2-roadmap.md index 5effb22..042201a 100644 --- a/docs/v2-roadmap.md +++ b/docs/v2-roadmap.md @@ -46,7 +46,7 @@ Not planned: voice and video calls, stories, usernames and phone-number lookup, - [x] **Shared lists and notes (M): done (notes.js, the Notes tab):** a real Notes and Lists tab the whole family edits (grocery, packing, gift ideas, house to-dos) - [x] **Chores and routines (M):** recurring chores per person, check-offs, optional points and a weekly summary; kids see their own **Done: chores.js and the Chores tab: repeats, points, weekly and monthly board.** - [x] **Family calendar (M):** **Done: the Family calendar shows each event in the colour of whoever added it (Settings > Look sets yours); birthdays and anniversaries come from Contacts; inviting people puts the event on their calendar and tells them (no accept or decline). The week view already existed.** -- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry counts are kept by hand; they do not go down when you cook.** +- [x] **Meal planning (M):** **Done: a shared week plan (Recipes > Meal plan) that adds the week's ingredients to the shopping list, and a Pantry (items with a count and a running-low level) that the plan checks against. Pantry items can have units (10 lb of flour); **Cooked it** on a recipe (or on a meal plan entry) shows what it would take out, converting cups to pounds for common ingredients, and takes it out when confirmed; **Use some** takes an amount in any convertible unit. These are estimates, and lines that cannot be converted (a whole onion against pounds of onions, pinches) are left alone and listed. The Pantry lives at Recipes > Pantry and works without Mealie. A phone can scan a barcode to add or use one (several barcodes can belong to one item, so different brands of corn share a count), and a Take one screen lets anyone, children included, tap an item to take one; children can do nothing else to the pantry.** - [x] **Worship and devotion (S):** a shared daily verse, a Bible reading plan with check-ins, a family prayer list **Done: Bible > Devotion (devotion-view.js, bible.js).** ## 3. Kids and safety diff --git a/pantry.js b/pantry.js new file mode 100644 index 0000000..764bed6 --- /dev/null +++ b/pantry.js @@ -0,0 +1,115 @@ +// The pantry: what the family keeps in the kitchen, one shared list. Each item has a name, how many are left (blank when you do not count it), the level it counts as running low +// at (blank: never), and any number of barcodes linked to it, so Green Giant corn and the store brand are both "Corn" with one count. Scanning a barcode (on a phone) looks the product +// up in Open Food Facts (through this server, so the phone never contacts it) and adds or uses one; the first time a barcode is seen the person says which item it is. +// An item going low starts the "pantry item runs low" automations. +const fs = require('fs'), path = require('path'), crypto = require('crypto'); +const atomicWrite = require('./atomic'); +const users = require('./users'); +const { readBody } = require('./body'); +const U = require('./units'); + +const DATA = () => process.env.DATA_DIR || path.join(__dirname, 'data'); +const file = () => path.join(DATA(), 'pantry.json'), cacheFile = () => path.join(DATA(), 'barcode-cache.json'); +const OFF = () => (process.env.OPENFOODFACTS_URL || 'https://world.openfoodfacts.org').replace(/\/+$/, ''); +const json = (res, code, o) => { res.writeHead(code, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(o)); }; +const fail = (code, message) => Object.assign(new Error(message), { code }); +const unitClean = v => U.UNITS.includes(String(v || '')) ? String(v || '') : ''; +const num = v => v === '' || v == null || !isFinite(+v) || +v < 0 ? null : Math.min(100000, Math.round(+v * 100) / 100); +const CODE = /^\d{8,14}$/; + +const read = () => { try { const a = JSON.parse(fs.readFileSync(file(), 'utf8')); return (Array.isArray(a) ? a : []).map(x => typeof x === 'string' ? { id: crypto.randomBytes(4).toString('hex'), name: x, qty: null, low: null, unit: '', codes: [] } : { ...x, unit: unitClean(x.unit), codes: Array.isArray(x.codes) ? x.codes : [] }).filter(x => x && x.name); } catch { return []; } }; +const write = l => atomicWrite(file(), JSON.stringify(l), { mode: 0o600 }); +const isLow = x => x.qty != null && x.low != null && x.qty <= x.low; +const view = l => l.map(x => ({ id: x.id, name: x.name, qty: x.qty, unit: x.unit || '', label: U.fmt(x.qty, x.unit || ''), low: x.low, lowLabel: U.fmt(x.low, x.unit || ''), isLow: isLow(x), codes: x.codes })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name)); +// Who took what: the last few uses, so parents can see where the snacks went. +const logFile = () => path.join(DATA(), 'pantry-log.json'); +const readLog = () => { try { const a = JSON.parse(fs.readFileSync(logFile(), 'utf8')); return Array.isArray(a) ? a : []; } catch { return []; } }; +function logUse(me, it, delta, note) { const l = readLog(); l.unshift({ t: Date.now(), who: me.name, item: it.name, delta, ...(note ? { note: String(note).slice(0, 80) } : {}) }); atomicWrite(logFile(), JSON.stringify(l.slice(0, 60)), { mode: 0o600 }); } +const isKid = u => !!(u && u.kid && u.kid.on); +/** An item has just gone low: tell the automations of everyone who asked to hear about it. */ +function lowNow(it) { try { require('./automations').fireAll('pantry.low', { title: it.name }); } catch (e) { console.warn('[pantry] rules failed:', e.message); } } +/** Change an item's count, firing the "went low" rules when it crosses into low. */ +function bump(it, delta) { const before = isLow(it); it.qty = Math.max(0, Math.round(((it.qty || 0) + delta) * 10000) / 10000); if (isLow(it) && !before) lowNow(it); } + +// ---- matching words: "3 cups flour" has "flour"; "Green Giant Sweet Corn" has "Corn" +const stem = w => w.replace(/(ies)$/, 'y').replace(/(es|s)$/, ''); +const wordsOf = t => (String(t).toLowerCase().match(/[a-z]+/g) || []).map(stem); +/** The pantry names (from items or strings) that every word of is in `text`. */ +function inPantry(text, items) { const have = new Set(wordsOf(text)); return items.map(x => typeof x === 'string' ? x : x.name).filter(n => { const ws = wordsOf(n); return ws.length && ws.every(w => have.has(w)); }); } +/** A short name for a product: the brand and the size taken off ("Heinz Tomato Ketchup 500 ml" -> "Tomato Ketchup"). */ +function shortName(name, brand) { + let n = String(name || '').replace(/\b\d+([.,]\d+)?\s?(x\s?\d+\s?)?(ml|cl|l|g|kg|oz|fl\.?\s?oz|lb|lbs|ct|count|pack|pk)\b\.?/gi, ' '); + for (const b of String(brand || '').split(',').map(x => x.trim()).filter(Boolean)) n = n.replace(new RegExp('\\b' + b.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b', 'ig'), ' '); + n = n.replace(/[()\[\],]/g, ' ').replace(/\s+/g, ' ').trim(); return (n || String(name || '').trim()).replace(/\b\w/g, c => c.toUpperCase()).slice(0, 60); +} + +// ---- the product database (Open Food Facts), remembered so a barcode is only looked up once +const loadCache = () => { try { return JSON.parse(fs.readFileSync(cacheFile(), 'utf8')) || {}; } catch { return {}; } }; +async function product(code) { + const c = loadCache(), hit = c[code]; if (hit && (hit.found || Date.now() - hit.t < 24 * 3600e3)) return hit.found ? hit : null; // (a miss is tried again after a day) + let found = null; + try { + const r = await fetch(`${OFF()}/api/v2/product/${code}.json?fields=product_name,brands,quantity,generic_name`, { headers: { 'user-agent': 'NE-Hub/1.0 (family pantry)', accept: 'application/json' }, signal: AbortSignal.timeout(6000) }); + if (r.ok) { const j = await r.json(), p = j && j.status === 1 && j.product; if (p && (p.product_name || p.generic_name)) found = { name: String(p.product_name || p.generic_name).slice(0, 120), brand: String(p.brands || '').slice(0, 80), quantity: String(p.quantity || '').slice(0, 30) }; } + } catch { return undefined; } // (could not ask: not remembered) + c[code] = found ? { found: true, t: Date.now(), ...found } : { found: false, t: Date.now() }; + const keys = Object.keys(c); if (keys.length > 3000) for (const k of keys.sort((a, b) => c[a].t - c[b].t).slice(0, 500)) delete c[k]; + fs.mkdirSync(DATA(), { recursive: true }); atomicWrite(cacheFile(), JSON.stringify(c), { mode: 0o600 }); return found ? c[code] : null; +} + +async function handle(req, res) { + const me = users.current(), u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/pantry'.length) || '/'; + try { + if (!me) throw fail(401, 'Sign in'); + if (p === '/' && req.method === 'GET') return json(res, 200, { items: view(read()), recent: readLog().slice(0, 12), kid: isKid(me) }); + if (p === '/lookup' && req.method === 'GET') { // what is this barcode? Linked to an item already, or which products and items it might be + const code = u.searchParams.get('code') || ''; if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)'); + const l = read(), linked = l.find(x => x.codes.includes(code)); if (linked) return json(res, 200, { code, linked: { id: linked.id, name: linked.name } }); + const pr = await product(code), name = pr ? shortName(pr.name, pr.brand) : ''; + return json(res, 200, { code, linked: null, product: pr ? { name: pr.name, brand: pr.brand, quantity: pr.quantity, short: name } : null, lookedUp: pr !== undefined, matches: pr ? view(l.filter(x => inPantry(pr.name, [x.name]).length)).map(x => ({ id: x.id, name: x.name })) : [] }); + } + if (req.method !== 'POST') throw fail(404, 'Not found'); + const b = JSON.parse(await readBody(req, 4e3) || '{}'), l = read(); + if (p === '/use') { // the simple way: take one (or put it back, to undo). Open to everyone, children included. + const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); const delta = b.undo ? 1 : -1; + if (it.unit) throw fail(409, 'Use an amount for ' + it.name.toLowerCase() + ' (it is kept in ' + it.unit + ')'); + if (it.qty != null) { if (delta < 0 && it.qty <= 0) throw fail(409, 'There is none left of ' + it.name); bump(it, delta); write(l); } + logUse(me, it, b.undo ? 0 : -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, isLow: isLow(it) }, items: view(l), recent: readLog().slice(0, 12) }); + } + if (isKid(me) && !(p === '/scan' && b.mode === 'use' && l.some(x => x.codes.includes(String(b.code))))) throw fail(403, 'Ask a grown-up to change the pantry. You can tap an item to take one.'); + if (p === '/item') { // add an item, or change one (the same name changes the existing item) + const name = String(b.name || '').replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); if (!name) throw fail(400, 'Give the item a name'); + const i = b.id ? l.findIndex(x => x.id === b.id) : l.findIndex(x => x.name.toLowerCase() === name.toLowerCase()); if (b.id && i < 0) throw fail(404, 'That item is gone'); if (i < 0 && l.length >= 400) throw fail(400, 'The pantry is full'); + const before = i >= 0 ? isLow(l[i]) : false, it = { id: i >= 0 ? l[i].id : crypto.randomBytes(4).toString('hex'), name: i >= 0 && !b.id ? l[i].name : name, qty: num(b.qty), unit: unitClean(b.unit), low: num(b.low), codes: i >= 0 ? l[i].codes : [] }; + if (i >= 0) l[i] = it; else l.push(it); write(l); if (isLow(it) && !before) lowNow(it); return json(res, 200, { items: view(l) }); + } + if (p === '/adjust') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); bump(it, +b.delta || 0); write(l); return json(res, 200, { items: view(l) }); } + if (p === '/delete') { const k = l.filter(x => x.id !== b.id); write(k); return json(res, 200, { items: view(k) }); } + if (p === '/take') { // take amounts out (cooking, or a spoonful from the bag): [{ id, amount, unit }], the unit being any that can be turned into the item's own + const takes = Array.isArray(b.takes) ? b.takes.slice(0, 80) : [], done = [], skipped = []; + for (const t of takes) { + const it = l.find(x => x.id === t.id), amt = +t.amount; if (!it || !(amt > 0)) { skipped.push({ id: t.id, why: 'nothing to take' }); continue; } + if (it.qty == null) { skipped.push({ id: it.id, name: it.name, why: 'not counted' }); continue; } + const unit = t.unit == null ? (it.unit || '') : U.unitOf(t.unit) || '', c = unit === (it.unit || '') ? amt : U.convert(amt, unit, it.unit || '', it.name); + if (c == null) { skipped.push({ id: it.id, name: it.name, why: `cannot turn ${unit || 'a count'} into ${it.unit || 'a count'} for ${it.name.toLowerCase()}` }); continue; } + const before = it.qty; bump(it, -Math.round(c * 10000) / 10000); done.push({ id: it.id, name: it.name, took: Math.round((before - it.qty) * 10000) / 10000, label: U.fmt(it.qty, it.unit || ''), isLow: isLow(it) }); + } + if (done.length) { write(l); const first = done.map(x => x.name).slice(0, 3).join(', '); logUse(me, { name: first + (done.length > 3 ? '…' : '') }, -1, b.note || 'cooked'); } + return json(res, 200, { done, skipped, items: view(l), recent: readLog().slice(0, 12) }); + } + if (p === '/scan') { // one scanned: add one ("add") or use one ("use"). A new barcode must say which item it is (itemId) or name a new one (name). + const code = String(b.code || ''); if (!CODE.test(code)) throw fail(400, 'That is not a barcode number (8 to 14 digits)'); const delta = b.mode === 'use' ? -1 : 1; + let it = l.find(x => x.codes.includes(code)), made = false; + if (!it && b.itemId) { it = l.find(x => x.id === b.itemId); if (!it) throw fail(404, 'That item is gone'); } + if (!it && String(b.name || '').trim()) { const name = String(b.name).replace(/[\u0000-\u001f]/g, '').trim().slice(0, 60); it = l.find(x => x.name.toLowerCase() === name.toLowerCase()); if (!it) { if (l.length >= 400) throw fail(400, 'The pantry is full'); it = { id: crypto.randomBytes(4).toString('hex'), name, qty: 0, low: null, unit: unitClean(b.unit), codes: [] }; l.push(it); made = true; } } + if (!it) throw fail(400, 'Say which item this is'); + if (!it.codes.includes(code)) { for (const x of l) x.codes = x.codes.filter(c => c !== code); it.codes.push(code); it.codes = it.codes.slice(-30); } + let step = delta; // an item measured in pounds, cups and so on goes up and down by the size of the package scanned (a 15 oz can into an item in ounces); a counted item by one + if (it.unit) { const pk = (loadCache()[code] || {}).quantity, pi = pk ? U.parseIngredient(pk) : null; if (pi && pi.amount && pi.unit) { const c = U.convert(pi.amount, pi.unit, it.unit, it.name); if (c && c > 0) step = delta * Math.round(c * 100) / 100; } } + bump(it, step); write(l); if (delta < 0) logUse(me, it, -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, label: U.fmt(it.qty, it.unit || ''), isLow: isLow(it) }, step, made, items: view(l), recent: readLog().slice(0, 12) }); + } + if (p === '/unlink') { const it = l.find(x => x.id === b.id); if (!it) throw fail(404, 'That item is gone'); it.codes = it.codes.filter(c => c !== String(b.code)); write(l); return json(res, 200, { items: view(l) }); } + throw fail(404, 'Not found'); + } catch (e) { json(res, e.code && e.code < 600 ? e.code : 400, { error: e.message }); } +} +module.exports = { handle, read, isLow, inPantry, shortName }; diff --git a/public/index.html b/public/index.html index 9ab2e84..eab398b 100644 --- a/public/index.html +++ b/public/index.html @@ -81,6 +81,7 @@ + diff --git a/public/pantry-view.js b/public/pantry-view.js new file mode 100644 index 0000000..ac9f4f3 --- /dev/null +++ b/public/pantry-view.js @@ -0,0 +1,101 @@ +'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; } }; +} diff --git a/public/recipes-view.js b/public/recipes-view.js index df0612c..8841c9e 100644 --- a/public/recipes-view.js +++ b/public/recipes-view.js @@ -4,7 +4,7 @@ const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts); const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined }); const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; }; -const rcTabs = on => ``; +const rcTabs = on => ``; let rcQuery = '', rcPage = 1; async function viewRecipes(args = []) { @@ -12,12 +12,14 @@ async function viewRecipes(args = []) { v.innerHTML = 'Loading…
${esc(e.message)}
${esc(e.message)}
${esc(r.description)}
` : ''} ${facts.length ? `${facts.map(f => `${esc(f[0])} ${esc(f[1])}`).join(' · ')}
` : ''} ${r.orgUrl ? `` : ''}${esc(i.title)}
` : ``).join('') || 'No ingredients.
'}You may already have ${esc(names.slice(0, 8).join(', '))}${names.length > 8 ? '…' : ''} (${r.items.length} ingredient${r.items.length === 1 ? '' : 's'} this week). They still go on the shopping list; tick them off there.
`; }).catch(() => {}); $('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; }; } @@ -138,21 +141,21 @@ function rcPlanPick(slug, name, rid, date) { el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find(); } -/** The pantry: what you keep in stock, how many are left, and the level that counts as running low (low ones go on the shopping list in a tap). */ -async function rcPantry(onClose) { - let items = []; try { items = (await rcApi('/pantry')).items; } catch (e) { return toast(e.message); } - const el = openSheet('Pantry', 'What you keep in stock. The meal plan points out ingredients you have, and an item running low can start an automation.', ''); - const draw = () => { - el.querySelector('#pyl').innerHTML = ` -Working it out…
${esc(e.message)}
`); return; } + const ok = r.rows.filter(x => x.ok), skip = r.rows.filter(x => !x.ok), body = `Nothing in your pantry matches this recipe\'s ingredients that can be counted off.
'} + ${skip.length ? `These are estimates (a cup of flour is about 4¼ oz). Untick anything that is not right.
+