diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 90f454f..c62eacd 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -35,6 +35,7 @@ jobs: - 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 fe5cd0f..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 and by scanning; they do not go down when you cook. 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] **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 index 807f9ca..764bed6 100644 --- a/pantry.js +++ b/pantry.js @@ -6,28 +6,30 @@ 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, codes: [] } : { ...x, codes: Array.isArray(x.codes) ? x.codes : [] }).filter(x => x && x.name); } catch { return []; } }; +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, low: x.low, isLow: isLow(x), codes: x.codes })).sort((a, b) => b.isLow - a.isLow || a.name.localeCompare(b.name)); +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) { const l = readLog(); l.unshift({ t: Date.now(), who: me.name, item: it.name, delta }); atomicWrite(logFile(), JSON.stringify(l.slice(0, 60)), { mode: 0o600 }); } +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) * 100) / 100); if (isLow(it) && !before) lowNow(it); } +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)$/, ''); @@ -70,6 +72,7 @@ async function handle(req, res) { 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) }); } @@ -77,19 +80,33 @@ async function handle(req, res) { 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), low: num(b.low), codes: i >= 0 ? l[i].codes : [] }; + 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, codes: [] }; l.push(it); made = true; } } + 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); } - bump(it, delta); write(l); if (delta < 0) logUse(me, it, -1); return json(res, 200, { item: { id: it.id, name: it.name, qty: it.qty, isLow: isLow(it) }, made, items: view(l), recent: readLog().slice(0, 12) }); + 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'); diff --git a/public/pantry-view.js b/public/pantry-view.js index 8224220..ac9f4f3 100644 --- a/public/pantry-view.js +++ b/public/pantry-view.js @@ -1,9 +1,11 @@ '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 ? `

Recently

${r.map(x => `
${esc(x.who)} ${x.delta < 0 ? 'took' : 'put back'} ${esc(x.item)}${esc(paAgo(x.t))}
`).join('')}
` : ''; +const paRecent = r => r.length ? `

Recently

${r.map(x => `
${esc(x.who)} ${x.delta < 0 ? 'took' : 'put back'} ${esc(x.item)}${esc(paAgo(x.t))}${x.note ? ' · ' + esc(x.note) : ''}
`).join('')}
` : ''; /** 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); @@ -12,9 +14,9 @@ async function viewPantryTake() { const draw = () => { v.innerHTML = `

${kid ? 'Pantry' : 'Take one'}

${kid ? '' : `Manage`}

Tap what you are taking. It counts one off so the family knows when to buy more.

-
${st.items.map(x => ``).join('') || '

Nothing in the pantry yet.

'}
+
${st.items.filter(x => !kid || !x.unit).map(x => ``).join('') || '

Nothing in the pantry yet.

'}
${paRecent(st.recent || [])}
`; - 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; + v.querySelectorAll('[data-u]').forEach(b => b.onclick = async () => { const own = st.items.find(x => x.id === b.dataset.u); if (own && own.unit) return paUseSome(own, list => { st.items = list.items; st.recent = list.recent; draw(); }); 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); }; @@ -28,13 +30,14 @@ async function viewPantry(sub) { const draw = () => { v.innerHTML = `

Recipes

${typeof rcTabs === 'function' ? rcTabs('pantry') : ''}
Take one
-
-
${items.map(x => `
${esc(x.name)}${x.isLow ? ' running low' : ''}${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') : ''} - ${x.isLow && typeof rcApi === 'function' ? `` : ''}${x.codes.length ? `` : ''}${x.qty != null ? `` : ''}
`).join('') || '
Nothing yet. Add what you keep in the kitchen, or scan a barcode.
'}
+
+
${items.map(x => `
${esc(x.name)}${x.isLow ? ' running low' : ''}${x.qty == null ? 'not counted' : x.label + ' left'}${x.low != null ? ' · low at ' + x.lowLabel : ''}${x.codes.length ? ' · ' + x.codes.length + ' barcode' + (x.codes.length === 1 ? '' : 's') : ''} + ${x.isLow && typeof rcApi === 'function' ? `` : ''}${x.codes.length ? `` : ''}${x.qty != null && x.unit ? `` : ''}${x.qty != null ? `` : ''}
`).join('') || '
Nothing yet. Add what you keep in the kitchen, or scan a barcode.
'}

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)}
`; 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 }); }); + $('#paf').onsubmit = e => { e.preventDefault(); go('/item', { name: $('#pan').value, qty: $('#paq').value, unit: $('#pau').value, low: $('#paw').value }); }; + v.querySelectorAll('[data-some]').forEach(b => b.onclick = () => paUseSome(items.find(x => x.id === b.dataset.some), r => { items = r.items; recent = r.recent || recent; draw(); })); + v.querySelectorAll('[data-d]').forEach(b => b.onclick = () => { const i = b.dataset.d.lastIndexOf(':'); go('/adjust', { id: b.dataset.d.slice(0, i), delta: +b.dataset.d.slice(i + 1) }); }); 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 + '.', `
${it.codes.map(c => `
${esc(c)}
`).join('')}
`); el.querySelectorAll('[data-u]').forEach(u => u.onclick = async () => { closeSheet(); go('/unlink', { id: it.id, code: u.dataset.u }); }); }); @@ -89,3 +92,10 @@ function pantryScan(mode, onChange, getItems) { })(); 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 649fe06..8841c9e 100644 --- a/public/recipes-view.js +++ b/public/recipes-view.js @@ -62,7 +62,7 @@ async function rcOne(slug) {

${esc(r.name)}

${r.description ? `

${esc(r.description)}

` : ''} ${facts.length ? `

${facts.map(f => `${esc(f[0])} ${esc(f[1])}`).join(' · ')}

` : ''} ${r.orgUrl ? `

Original page

` : ''}
-
Shopping list${allowed('chat') ? '' : ''}
+
Shopping list${allowed('chat') ? '' : ''}

Ingredients

${r.ingredients.map((i, n) => i.title && !i.text ? `

${esc(i.title)}

` : ``).join('') || '

No ingredients.

'}

Steps

    ${r.steps.map((s, n) => `
  1. ${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, '
    ')}
  2. `).join('') || '
  3. No steps.
  4. '}
@@ -80,7 +80,7 @@ async function rcOne(slug) { $('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); }; paint(); if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug)); - $('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id); + $('#rcplan').onclick = () => rcPlanPick(r.slug, r.name, r.id); $('#rccook').onclick = () => rcCook(r.slug, r.name); $('#rcadd').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/shopping/add-recipe', rcOpt('POST', { slug })); toast('Added to your shopping list'); b.disabled = false; return o; } @@ -119,10 +119,11 @@ async function rcPlan(start) {
${icon('back')}${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}${icon('chev')}
${days.map(d => { const k = rcYmd(d), es = (plan[k] || []).slice().sort((a, b) => RC_MEALS.findIndex(m => m[0] === a.meal) - RC_MEALS.findIndex(m => m[0] === b.meal)); return `
${d.toLocaleDateString(undefined, { weekday: 'short' })}${d.getDate()} - ${es.map(e => `${esc(e.name)}${e.meal !== 'dinner' ? ` · ${esc(e.meal)}` : ''}`).join('') || 'Nothing planned'} + ${es.map(e => `${esc(e.name)}${e.meal !== 'dinner' ? ` · ${esc(e.meal)}` : ''}`).join('') || 'Nothing planned'}
`; }).join('')}
`; document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add)); + document.querySelectorAll('[data-cook]').forEach(b => b.onclick = () => { const i = b.dataset.cook.indexOf('|'); rcCook(b.dataset.cook.slice(0, i), b.dataset.cook.slice(i + 1)); }); document.querySelectorAll('[data-rm]').forEach(b => b.onclick = async () => { try { await rcApi('/plan/remove', rcOpt('POST', { id: b.dataset.rm })); rcPlan(rcYmd(mon)); } catch (e) { toast(e.message); } }); $('#rcpantry').onclick = () => { location.hash = '#/recipes/pantry'; }; if (count) rcApi(`/plan/pantry?from=${from}&to=${to}`).then(r => { const box = $('#rcphave'); if (!box || !r.items.length) return; const names = [...new Set(r.items.map(i => i.have))]; box.innerHTML = `

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(() => {}); @@ -139,3 +140,22 @@ function rcPlanPick(slug, name, rid, date) { let t = 0; const find = async () => { try { const r = await rcApi('/?page=1&q=' + encodeURIComponent(el.querySelector('#rcpq').value)); el.querySelector('#rcpr').innerHTML = r.items.slice(0, 8).map(x => `${esc(x.name)}`).join('') || '
No recipes found
'; el.querySelectorAll('#rcpr [data-s]').forEach(a => a.onclick = ev => { ev.preventDefault(); save(a.dataset.s, a.dataset.n, a.dataset.i); }); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } }; el.querySelector('#rcpq').oninput = () => { clearTimeout(t); t = setTimeout(find, 250); }; find(); } + +/** "Cooked it": shows what cooking this recipe would take out of the pantry (an estimate), lets you untick lines, and takes the rest out when you confirm. */ +async function rcCook(slug, name, scale = 1) { + const el = openSheet('Cooked ' + name, 'What this takes out of the pantry', '

Working it out…

'); + const show = async sc => { + let r; try { r = await rcApi(`/cook-plan?slug=${encodeURIComponent(slug)}&scale=${sc}`); } catch (e) { el.querySelector('.sheet').insertAdjacentHTML('beforeend', `

${esc(e.message)}

`); return; } + const ok = r.rows.filter(x => x.ok), skip = r.rows.filter(x => !x.ok), body = `
${[[0.5, 'Half'], [1, 'As written'], [2, 'Double'], [3, 'Triple']].map(([v, l]) => `${l}`).join('')}
+ ${ok.length ? `
${ok.map((x, i) => ``).join('')}
` : '

Nothing in your pantry matches this recipe\'s ingredients that can be counted off.

'} + ${skip.length ? `

Left alone

${skip.map(x => `
${esc(x.item.name)}${esc(x.line)} · ${esc(x.why)}
`).join('')}
` : ''} +

These are estimates (a cup of flour is about 4¼ oz). Untick anything that is not right.

+
Pantry${ok.length ? '' : ''}
`; + el.querySelector('#rcck').outerHTML = body; + el.querySelectorAll('[data-sc]').forEach(a => a.onclick = e => { e.preventDefault(); show(+a.dataset.sc); }); + const go = el.querySelector('#rccgo'); if (go) go.onclick = async () => { const picks = [...el.querySelectorAll('[data-i]:checked')].map(c => ok[+c.dataset.i]); if (!picks.length) return closeSheet(); + try { const t = await jfetch('/api/pantry/take', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ takes: picks.map(x => ({ id: x.item.id, amount: x.take, unit: x.unit })), note: 'cooked ' + name }) }); closeSheet(); const low = t.done.filter(d => d.isLow).map(d => d.name); toast(`Took ${t.done.length} item${t.done.length === 1 ? '' : 's'} out of the pantry${low.length ? '. Running low: ' + low.join(', ') : ''}`, low.length ? { label: 'Pantry', run: () => { location.hash = '#/recipes/pantry'; } } : undefined); } + catch (e) { el.querySelector('#rcce').textContent = e.message; } }; + }; + show(scale); +} diff --git a/public/sw.js b/public/sw.js index cfc30e0..fe4d715 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,5 +1,5 @@ // App-shell cache only; Seafile/API traffic always goes to the network. -const CACHE = 'hub-v125'; +const CACHE = 'hub-v127'; const KEEP = /^hub-(offline|share|who|data-)/; // caches that outlive a new version const SHARE = 'hub-share'; // files shared into the app from other apps, held until the person picks a folder const SHELL = ['/icon.svg', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/apple-touch-icon.png']; diff --git a/recipes.js b/recipes.js index 92884f7..8213792 100644 --- a/recipes.js +++ b/recipes.js @@ -104,7 +104,7 @@ const DAY = /^\d{4}-\d{2}-\d{2}$/, MEALS = ['breakfast', 'lunch', 'dinner'], pla const readPlan = () => { try { const d = JSON.parse(fs.readFileSync(planFile(), 'utf8')); return { days: d && d.days && typeof d.days === 'object' ? d.days : {} }; } catch { return { days: {} }; } }; const writePlan = d => atomicWrite(planFile(), JSON.stringify(d), { mode: 0o600 }); const planBetween = (from, to) => { const out = {}; for (const [k, v] of Object.entries(readPlan().days)) if (k >= from && k <= to) out[k] = v; return out; }; -const pantry = require('./pantry'); // what is in stock (the plan checks its ingredients against it) +const pantry = require('./pantry'), U = require('./units'); // what is in stock (the plan checks its ingredients against it) /** A recipe's ingredients onto Mealie's shopping list, `times` over (Mealie adds up what is already there). */ async function addRecipe(slug, times) { const r = await mealie('/api/recipes/' + (slugOk(slug) ? slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +times || 1)); @@ -153,6 +153,16 @@ async function handle(req, res) { if (d.days[b.date].length > 12) throw fail(400, 'That day is full'); writePlan(d); return json(res, 200, { ok: true, id }); } if (p === '/plan/remove' && req.method === 'POST') { const b = JSON.parse(await readBody(req, 1e4) || '{}'), d = readPlan(); for (const k of Object.keys(d.days)) { d.days[k] = d.days[k].filter(e => e.id !== b.id); if (!d.days[k].length) delete d.days[k]; } writePlan(d); return json(res, 200, { ok: true }); } + if (p === '/cook-plan' && req.method === 'GET') { // what cooking this recipe would take out of the pantry (nothing is changed here; the person confirms in the pantry) + const slug = q.get('slug') || '', scale = Math.max(0.25, Math.min(20, +q.get('scale') || 1)); if (!slugOk(slug)) throw fail(400, 'Pick a recipe'); + const r = await mealie('/api/recipes/' + slug), ings = (r.recipeIngredient || []).filter(i => i && !(i.title && !i.display && !i.originalText && !i.food)).map(i => { + const line = ingredientText(i), qn = +i.quantity, food = i.food && (i.food.name || i.food), uname = i.unit && (i.unit.name || i.unit); + if (qn > 0 && food) return { amount: qn, unit: U.unitOf(uname) || '', name: [food, i.note].filter(Boolean).join(' '), line }; // (Mealie already split it up) + return { ...U.parseIngredient(line), line }; + }); + const rows = U.plan(ings, pantry.read(), scale).map(x => ({ ...x, takeLabel: U.fmt(x.take, x.unit), leftLabel: x.left == null ? '' : U.fmt(x.left, x.unit) })); + return json(res, 200, { name: r.name, servings: r.recipeServings || r.recipeYield || '', scale, rows, lines: ings.length }); + } if (p === '/plan/pantry' && req.method === 'GET') { // which planned ingredients the pantry already covers const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); const items = pantry.read().filter(x => x.qty !== 0), out = []; if (!items.length) return json(res, 200, { items: [] }); for (const sl of [...new Set(Object.values(planBetween(from, to)).flat().map(e => e.slug))]) { try { const r = full(await mealie('/api/recipes/' + sl)); for (const i of r.ingredients) { const hit = i.text && pantry.inPantry(i.text, items); if (hit && hit.length) out.push({ recipe: r.name, text: i.text, have: hit[0] }); } } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } } diff --git a/tools/pantry-test.js b/tools/pantry-test.js index b982db6..60891f4 100644 --- a/tools/pantry-test.js +++ b/tools/pantry-test.js @@ -52,6 +52,16 @@ const off = http.createServer((req, res) => { const code = /product\/(\d+)\.json for (const [p, o] of [['/item', { name: 'Candy', qty: 9 }], ['/adjust', { id: gb, delta: 5 }], ['/delete', { id: gb }], ['/unlink', { id: gb, code: '1' }], ['/scan', { code: '0099999999999', mode: 'add', name: 'Candy' }]]) assert.equal((await post(p, o, 'kid')).status, 403, p + ' is for grown-ups'); assert.equal((await get('/')).status, 200); assert.equal((await j(await fetch(base + '/', { headers: { 'x-user': 'kid' } }))).kid, true); r = await j(await post('/scan', { code: '0016000275287', itemId: corn })); const before = r.item.qty; r = await j(await post('/scan', { code: '0016000275287', mode: 'use' }, 'kid')); assert.equal(r.item.qty, before - 1, 'a child can scan a known barcode to take one'); assert.equal((await post('/scan', { code: '0555555555555', mode: 'use', name: 'Mystery' }, 'kid')).status, 403, 'but not make new items'); + // units: an item in pounds, amounts in cups, taken out together (grown-ups only), and the shown amount + r = await j(await post('/item', { name: 'Flour', qty: 10, unit: 'lb', low: 2 })); let fl = r.items.find(x => x.name === 'Flour'); assert.deepEqual([fl.unit, fl.label, fl.lowLabel], ['lb', '10 lb', '2 lb']); assert.equal((await j(await post('/item', { name: 'Bad', qty: 1, unit: 'furlongs' }))).items.find(x => x.name === 'Bad').unit, '', 'an unknown unit is a count'); + r = await j(await post('/take', { takes: [{ id: fl.id, amount: 4, unit: 'cup' }], note: 'cooked Pancakes' })); assert.equal(r.done.length, 1); assert.ok(Math.abs(r.done[0].took - 4 * 120 / 453.592) < 0.01); assert.equal(r.items.find(x => x.name === 'Flour').label, '8 lb 15 oz', '10 lb less 4 cups'); assert.equal(r.recent[0].note, 'cooked Pancakes'); + r = await j(await post('/take', { takes: [{ id: fl.id, amount: 8, unit: 'oz' }, { id: fl.id, amount: 3, unit: 'furlongs' }, { id: 'nope', amount: 1 }, { id: note, amount: 1 }] })); assert.equal(r.done.length, 1); assert.equal(r.skipped.length, 3, 'what cannot be turned into the item\'s unit, is missing or is not counted is skipped, with a reason'); assert.match(r.skipped.find(x => x.name === 'Napkins').why, /not counted/); + r = await j(await post('/take', { takes: [{ id: fl.id, amount: 99, unit: 'lb' }] })); assert.equal(r.items.find(x => x.name === 'Flour').qty, 0, 'never below none'); assert.equal((await post('/take', { takes: [{ id: fl.id, amount: 1 }] }, 'kid')).status, 403, 'children only take one'); + assert.equal((await post('/use', { id: fl.id }, 'kid')).status, 409, 'tapping take-one on a pound of flour is not allowed: use an amount'); + // scanning goes by the size of the package for an item in a unit + r = await j(await post('/item', { name: 'Tomatoes', qty: 0, unit: 'oz' })); const tom = r.items.find(x => x.name === 'Tomatoes').id; const tcache = path.join(process.env.DATA_DIR, 'barcode-cache.json'); const cc = JSON.parse(fs.readFileSync(tcache, 'utf8')); cc['0041331000123'] = { found: true, t: Date.now(), name: 'Diced Tomatoes', brand: 'Muir', quantity: '14.5 oz' }; cc['0041331000456'] = { found: true, t: Date.now(), name: 'Tomato Paste', brand: 'Muir', quantity: '1 lb' }; fs.writeFileSync(tcache, JSON.stringify(cc)); + r = await j(await post('/scan', { code: '0041331000123', itemId: tom })); assert.equal(r.step, 14.5); assert.equal(r.item.qty, 14.5, 'a 14.5 oz can adds 14.5 oz'); r = await j(await post('/scan', { code: '0041331000456', itemId: tom })); assert.equal(r.step, 16, 'a 1 lb tin is 16 oz'); assert.equal(r.item.qty, 30.5); r = await j(await post('/scan', { code: '0041331000123', mode: 'use' })); assert.equal(r.item.qty, 16, 'using one takes the package size back off'); + r = await j(await post('/scan', { code: '0070470003344', itemId: gb, mode: 'add' })); assert.equal(r.step, 1, 'a counted item goes up by one'); // the pantry is shared, and old pantry files (a list of names) still load assert.equal((await j(await get('/', 'bo'))).items.length >= 1, true); fs.writeFileSync(path.join(process.env.DATA_DIR, 'pantry.json'), JSON.stringify(['Flour', 'Salt'])); r = await j(await get('/')); assert.deepEqual(r.items.map(x => x.name), ['Flour', 'Salt']); assert.equal(r.items[0].qty, null); assert.deepEqual(pantry.inPantry('3 cups all-purpose flour', ['Flour', 'Salt']), ['Flour']); diff --git a/tools/recipes-test.js b/tools/recipes-test.js index ec2f91c..2c2a9e5 100644 --- a/tools/recipes-test.js +++ b/tools/recipes-test.js @@ -68,6 +68,11 @@ const fake = http.createServer((req, res) => { j = await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json(); assert.deepEqual(j.items.map(i => i.text), ['3 cups flour']); assert.equal(j.items[0].have.toLowerCase(), 'flour'); fs.writeFileSync(pf, JSON.stringify([{ id: 'a1', name: 'Flour', qty: 0, low: 1, codes: [] }])); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty one is not "had"'); fs.writeFileSync(pf, '[]'); assert.deepEqual((await (await get('/plan/pantry?from=2027-05-03&to=2027-05-09')).json()).items, [], 'an empty pantry flags nothing'); + // what cooking a recipe would take out of the pantry (shown first; nothing changes until it is confirmed in the pantry) + fs.writeFileSync(pf, JSON.stringify([{ id: 'fl', name: 'Flour', qty: 10, unit: 'lb', low: 2, codes: [] }, { id: 'bu', name: 'Butter', qty: 1, unit: 'lb', low: null, codes: [] }, { id: 'ca', name: 'Cocoa', qty: null, unit: '', low: null, codes: [] }])); + j = await (await get('/cook-plan?slug=bread')).json(); assert.equal(j.name, 'Bread'); assert.deepEqual(j.rows.map(r => [r.item.name, r.ok]), [['Flour', true], ['Butter', true]], 'the glaze heading and anything not in the pantry are left out'); + assert.ok(Math.abs(j.rows[0].take - 3 * 120 / 453.592) < 0.01, '3 cups of flour in pounds'); assert.equal(j.rows[0].unit, 'lb'); assert.match(j.rows[0].takeLabel, /^(12|13) oz$/); assert.ok(Math.abs(j.rows[1].take - 0.0326) < 0.002, '1 tbsp of butter in pounds (Mealie gave it already split up)'); + j = await (await get('/cook-plan?slug=bread&scale=2')).json(); assert.ok(Math.abs(j.rows[0].take - 6 * 120 / 453.592) < 0.01, 'doubled'); assert.equal((await get('/cook-plan?slug=..%2Fx')).status, 400); // a wrong token is explained process.env.MEALIE_TOKEN = 'bad'; r = await get('/'); assert.equal(r.status, 502); assert.match((await r.json()).error, /token/); process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503); diff --git a/tools/units-test.js b/tools/units-test.js new file mode 100644 index 0000000..28ecc23 --- /dev/null +++ b/tools/units-test.js @@ -0,0 +1,29 @@ +// Units: reading recipe lines, converting (cups of flour to pounds), showing amounts, and working out what a recipe takes out of the pantry. +const assert = require('assert'), U = require('../units'); +const P = U.parseIngredient, near = (a, b, tol = 0.02) => assert.ok(Math.abs(a - b) <= tol * Math.max(1, Math.abs(b)), `${a} is not about ${b}`); +// reading lines +assert.deepEqual(P('3 cups all-purpose flour'), { amount: 3, unit: 'cup', name: 'all-purpose flour' }); +assert.deepEqual(P('1 1/2 tsp salt'), { amount: 1.5, unit: 'tsp', name: 'salt' }); assert.deepEqual(P('½ cup sugar'), { amount: 0.5, unit: 'cup', name: 'sugar' }); assert.deepEqual(P('1½ cups milk'), { amount: 1.5, unit: 'cup', name: 'milk' }); +assert.deepEqual(P('2-3 tbsp olive oil'), { amount: 2.5, unit: 'tbsp', name: 'olive oil' }, 'a range is its middle'); assert.deepEqual(P('1 lb. ground beef, browned'), { amount: 1, unit: 'lb', name: 'ground beef' }); +assert.deepEqual(P('2 large eggs'), { amount: 2, unit: '', name: 'large eggs' }); assert.deepEqual(P('1 onion, diced'), { amount: 1, unit: '', name: 'onion' }); assert.deepEqual(P('2 (14 oz) cans diced tomatoes'), { amount: 28, unit: 'oz', name: 'diced tomatoes' }); +assert.deepEqual(P('Salt and pepper to taste'), { amount: null, unit: '', name: 'Salt and pepper to taste' }); assert.deepEqual(P('1 stick butter'), { amount: 1, unit: 'stick', name: 'butter' }); assert.deepEqual(P('8 fl oz cream'), { amount: 8, unit: 'fl oz', name: 'cream' }); assert.deepEqual(P('1 can corn'), { amount: 1, unit: '', name: 'can corn' }); +assert.deepEqual(P('4 c. flour'), { amount: 4, unit: 'cup', name: 'flour' }); assert.deepEqual(P('2 T sugar'), { amount: 2, unit: 'tbsp', name: 'sugar' }); assert.deepEqual(P('2 cloves garlic'), { amount: 2, unit: '', name: 'cloves garlic' }); +// converting +near(U.convert(1, 'lb', 'oz'), 16); near(U.convert(1, 'cup', 'tbsp'), 16); near(U.convert(1, 'gallon', 'quart'), 4); near(U.convert(1000, 'g', 'kg'), 1); assert.equal(U.convert(2, 'cup', 'lb', 'bananas'), null, 'cups to pounds needs to know the ingredient'); +near(U.convert(4, 'cup', 'lb', 'all-purpose flour'), 4 * 120 / 453.592, 0.01); near(U.convert(1, 'lb', 'cup', 'flour'), 453.592 / 120, 0.01); near(U.convert(1, 'cup', 'oz', 'brown sugar'), 213 / 28.3495, 0.01); near(U.convert(1, 'stick', 'oz', 'butter'), 4, 0.02); assert.equal(U.convert(1, 'cup', 'lb', 'unobtainium'), null); assert.equal(U.convert(2, '', 'lb', 'flour'), null, 'a count does not become a weight'); +assert.equal(U.density('light brown sugar, packed'), 213, 'the longest match wins'); assert.equal(U.density('sugar'), 200); assert.equal(U.density('bananas'), 0); +// showing amounts +assert.equal(U.fmt(8.94, 'lb'), '8 lb 15 oz'); assert.equal(U.fmt(10, 'lb'), '10 lb'); assert.equal(U.fmt(0.94, 'lb'), '15 oz'); assert.equal(U.fmt(2.5, 'cup'), '2 1/2 cups'); assert.equal(U.fmt(1, 'cup'), '1 cup'); assert.equal(U.fmt(12, ''), '12'); assert.equal(U.fmt(null, 'lb'), ''); assert.equal(U.fmt(0.99999, 'lb'), '1 lb'); assert.equal(U.fmt(0.91, 'gallon'), '3 qt 2 1/2 cups'); assert.equal(U.fmt(0.09, 'gallon'), '1 1/2 cups'); assert.equal(U.fmt(1, 'gallon'), '1 gallon'); assert.equal(U.fmt(0.5, 'quart'), '2 cups'); assert.equal(U.fmt(2.25, 'quart'), '2 qt 1 cup'); +// what cooking takes out of the pantry +const items = [{ id: 'f', name: 'Flour', qty: 10, unit: 'lb' }, { id: 'e', name: 'Eggs', qty: 12, unit: '' }, { id: 's', name: 'Salt', qty: null, unit: '' }, { id: 'b', name: 'Butter', qty: 2, unit: 'lb' }, { id: 'm', name: 'Milk', qty: 1, unit: 'gallon' }, { id: 'o', name: 'Onions', qty: 3, unit: 'lb' }, { id: 'bs', name: 'Brown sugar', qty: 2, unit: 'lb' }, { id: 'su', name: 'Sugar', qty: 4, unit: 'lb' }]; +const pl = U.plan(['4 cups all-purpose flour', '2 large eggs', '1 tsp salt', '1 stick butter', '2 cups milk', '1 onion, diced', '1 cup packed brown sugar', 'pinch of nutmeg', 'Glaze'], items, 1); +const by = id => pl.find(r => r.item.id === id); +assert.ok(by('f').ok); near(by('f').take, 4 * 120 / 453.592, 0.01); near(by('f').left, 10 - 4 * 120 / 453.592, 0.01, 'flour: 4 cups is about 1 lb 1 oz of the 10 lb'); +assert.deepEqual([by('e').ok, by('e').take, by('e').left], [true, 2, 10], 'eggs are counted'); assert.equal(by('s').ok, false); assert.match(by('s').why, /not counted/); +near(by('b').take, 0.25, 0.02); near(by('m').take, 0.125, 0.01); assert.equal(by('o').ok, false); assert.match(by('o').why, /recipe counts onions/, 'a whole onion cannot come off pounds'); +near(by('bs').take, 213 / 453.592, 0.01); assert.equal(pl.find(r => r.item.id === 'su'), undefined, '"brown sugar" is not plain sugar... and sugar is not listed for it'); assert.equal(pl.length, 7, 'lines that match nothing are left out'); +// scaling, and not going below none +near(U.plan(['4 cups flour'], items, 2)[0].take, 8 * 120 / 453.592, 0.01); const tiny = U.plan(['4 cups flour'], [{ id: 'f', name: 'Flour', qty: 0.5, unit: 'lb' }])[0]; assert.equal(tiny.left, 0); assert.match(tiny.why, /more than the pantry has/); +{ const two = U.plan(['2 cups flour', '1 cup flour'], [{ id: 'f', name: 'Flour', qty: 1, unit: 'lb' }]); near(two[1].left, 1 - 3 * 120 / 453.592, 0.02); /* two lines of the same item add up */ } +assert.equal(U.plan(['3 cups flour'], [{ id: 'f', name: 'Flour', qty: 5, unit: 'cup' }])[0].take, 3, 'same unit: no conversion'); near(U.plan(['1 tsp vanilla'], [{ id: 'v', name: 'Vanilla', qty: 4, unit: 'tbsp' }])[0].left, 3.6667, 0.001); +console.log('units test ok'); diff --git a/units.js b/units.js new file mode 100644 index 0000000..829fe05 --- /dev/null +++ b/units.js @@ -0,0 +1,81 @@ +// Amounts and units for the pantry and recipes: read "1 1/2 cups all-purpose flour" into an amount, a unit and a name; convert between units (cups to pounds needs to know the +// ingredient, so there is a table of common ones); show an amount nicely ("8 lb 15 oz"); and work out what cooking a recipe takes out of the pantry. Everything here is an estimate: +// it is for keeping count of the flour, not for the lab. +const VOL = { ml: 1, l: 1000, tsp: 4.92892, tbsp: 14.7868, 'fl oz': 29.5735, cup: 236.588, pint: 473.176, quart: 946.353, gallon: 3785.41, stick: 118.294 }; // to millilitres (a stick of butter is half a cup) +const WT = { g: 1, kg: 1000, oz: 28.3495, lb: 453.592 }; // to grams +const ALIAS = { c: 'cup', cups: 'cup', tablespoon: 'tbsp', tablespoons: 'tbsp', tbs: 'tbsp', tbl: 'tbsp', tbsps: 'tbsp', t: 'tsp', teaspoon: 'tsp', teaspoons: 'tsp', tsps: 'tsp', ounce: 'oz', ounces: 'oz', ozs: 'oz', pound: 'lb', pounds: 'lb', lbs: 'lb', gram: 'g', grams: 'g', gr: 'g', kilogram: 'kg', kilograms: 'kg', kgs: 'kg', milliliter: 'ml', milliliters: 'ml', millilitre: 'ml', millilitres: 'ml', liter: 'l', liters: 'l', litre: 'l', litres: 'l', pt: 'pint', pints: 'pint', qt: 'quart', quarts: 'quart', gal: 'gallon', gallons: 'gallon', floz: 'fl oz', 'fl. oz': 'fl oz', 'fl oz.': 'fl oz', sticks: 'stick' }; +/** The canonical unit for a word ("Cups" -> "cup", "lbs." -> "lb"), or '' when it is not a unit this knows. */ +function unitOf(w) { const s = String(w || '').toLowerCase().trim().replace(/\.$/, ''); if (VOL[s] || WT[s]) return s; return ALIAS[s] || ''; } +const family = u => WT[u] ? 'weight' : VOL[u] ? 'volume' : 'count'; +const UNITS = ['', 'g', 'kg', 'oz', 'lb', 'ml', 'l', 'tsp', 'tbsp', 'cup', 'fl oz', 'pint', 'quart', 'gallon']; + +// grams in one cup, for the things people bake and cook with (King Arthur, USDA). The longest matching name wins ("brown sugar" before "sugar"). +const DENSITY = { flour: 120, 'bread flour': 120, 'whole wheat flour': 120, 'almond flour': 96, 'coconut flour': 112, 'rice flour': 158, sugar: 200, 'brown sugar': 213, 'powdered sugar': 120, 'confectioners sugar': 120, 'icing sugar': 120, butter: 227, margarine: 227, shortening: 190, milk: 245, buttermilk: 245, cream: 238, 'heavy cream': 238, 'half and half': 242, water: 237, juice: 248, oil: 218, 'olive oil': 216, 'vegetable oil': 218, 'coconut oil': 218, honey: 340, molasses: 340, 'maple syrup': 322, 'corn syrup': 328, syrup: 322, rice: 185, 'brown rice': 190, quinoa: 170, oats: 90, oatmeal: 90, cornmeal: 157, cornstarch: 128, cocoa: 85, 'cocoa powder': 85, salt: 288, 'baking powder': 230, 'baking soda': 230, yeast: 144, 'peanut butter': 258, 'almond butter': 250, 'cream cheese': 227, 'sour cream': 240, yogurt: 245, 'chocolate chips': 170, raisins: 145, nuts: 120, almonds: 140, walnuts: 117, pecans: 109, cheese: 113, parmesan: 100, breadcrumbs: 108, lentils: 192, beans: 190, pasta: 100, mayonnaise: 220, ketchup: 240, 'tomato sauce': 245, 'chicken broth': 240, broth: 240, stock: 240, vinegar: 239, 'soy sauce': 255, coconut: 80, 'cottage cheese': 226, ricotta: 246, 'peanuts': 146, 'sunflower seeds': 140, 'chia seeds': 170, flaxseed: 168 }; +const stem = w => w.replace(/(ies)$/, 'y').replace(/(ves)$/, 'f').replace(/(es|s)$/, ''); +const words = t => (String(t).toLowerCase().replace(/&/g, ' and ').match(/[a-z]+/g) || []).map(stem); +/** Grams per cup for a named ingredient, or 0 when it is not in the table. */ +function density(name) { + const have = new Set(words(name)); let best = 0, bestLen = 0; + for (const [k, g] of Object.entries(DENSITY)) { const ks = words(k); if (ks.length >= bestLen && ks.every(w => have.has(w)) && (ks.length > bestLen || !best)) { best = g; bestLen = ks.length; } } + return best; +} +const FR = { '¼': 0.25, '½': 0.5, '¾': 0.75, '⅓': 1 / 3, '⅔': 2 / 3, '⅛': 0.125, '⅜': 0.375, '⅝': 0.625, '⅞': 0.875, '⅕': 0.2, '⅖': 0.4, '⅗': 0.6, '⅘': 0.8, '⅙': 1 / 6, '⅚': 5 / 6 }; +/** A leading amount ("1 1/2", "½", "1½", "2-3", "2 to 3", "0.5"): { n, rest } or null. A range counts as its middle. */ +function leadingNumber(text) { + let s = String(text || '').trim().replace(/[¼½¾⅓⅔⅛⅜⅝⅞⅕⅖⅗⅘⅙⅚]/g, m => ' ' + FR[m] + ' ').replace(/\s+/g, ' ').trim(); + const one = /^(\d+\s+\d+\/\d+|\d+\/\d+|\d+\.\d+|\d+\s+0?\.\d+|\d+|0?\.\d+)/.exec(s); if (!one) return null; + const val = t => { t = t.trim(); let m; if ((m = /^(\d+)\s+(\d+)\/(\d+)$/.exec(t))) return +m[1] + +m[2] / +m[3]; if ((m = /^(\d+)\/(\d+)$/.exec(t))) return +m[1] / +m[2]; if ((m = /^(\d+)\s+(0?\.\d+)$/.exec(t))) return +m[1] + +m[2]; return parseFloat(t); }; + let n = val(one[1]), rest = s.slice(one[0].length).trim(); const range = /^(?:-|–|to)\s*(\d+\s+\d+\/\d+|\d+\/\d+|\d+\.\d+|\d+|0?\.\d+)/i.exec(rest); if (range) { n = (n + val(range[1])) / 2; rest = rest.slice(range[0].length).trim(); } + return isFinite(n) ? { n, rest } : null; +} +/** One recipe line -> { amount (number or null), unit ('' for a count), name }. "2 (14 oz) cans tomatoes" is 28 oz of tomatoes. */ +function parseIngredient(text) { + const t = String(text || '').trim(), q = leadingNumber(t); if (!q) return { amount: null, unit: '', name: t }; + let amount = q.n, rest = q.rest, unit = '', m; + if ((m = /^\(\s*([\d./\s]+)\s*([a-z. ]+?)\s*\)\s*/i.exec(rest)) && unitOf(m[2])) { const inner = leadingNumber(m[1]); if (inner) { amount = q.n * inner.n; unit = unitOf(m[2]); rest = rest.slice(m[0].length).replace(/^(cans?|jars?|packages?|pkgs?|bags?|boxes|box|bottles?|containers?)\b\s*/i, '').trim(); } } + else { const two = /^(fl\.?\s?oz\.?)\b/i.exec(rest); if (two) { unit = 'fl oz'; rest = rest.slice(two[0].length).trim(); } else if ((m = /^T(\s|$)/.exec(rest))) { unit = 'tbsp'; rest = rest.slice(m[0].length).trim(); } // (a capital T is a tablespoon, a small t a teaspoon) + else if ((m = /^([a-zA-Z]+\.?)(\s|$)/.exec(rest)) && unitOf(m[1]) && !(m[1].length === 1 && /^[a-z]$/.test(m[1]) && !/^[ctT]$/.test(m[1]))) { unit = unitOf(m[1]); rest = rest.slice(m[0].length).trim(); } } + rest = rest.replace(/^of\s+/i, '').replace(/\(.*?\)/g, ' ').replace(/,.*$/, '').replace(/\s+/g, ' ').trim(); + return { amount, unit, name: rest || t }; +} +/** Convert an amount between units. Weight and volume need the ingredient (a name, or grams per cup); a count converts only to a count. Returns null when it cannot. */ +function convert(amount, from, to, name, gPerCup) { + if (from === to) return amount; const ff = family(from), tf = family(to); + if (ff === 'count' || tf === 'count') return null; + if (ff === tf) { const t = ff === 'weight' ? WT : VOL; return amount * t[from] / t[to]; } + const g = gPerCup || density(name); if (!g) return null; const gPerMl = g / VOL.cup; + return ff === 'volume' ? amount * VOL[from] * gPerMl / WT[to] : amount * WT[from] / gPerMl / VOL[to]; +} +const r2 = n => Math.round(n * 100) / 100, r4 = n => Math.round(n * 10000) / 10000; // (amounts keep four decimals: a spoonful of butter is a few hundredths of a pound) +/** An amount to show: "8 lb 15 oz", "2 1/2 cups", "12". */ +function fmt(qty, unit) { + if (qty == null) return ''; const q = Math.round(qty * 100) / 100; + if (unit === 'lb' && q % 1) { let lb = Math.floor(q), oz = Math.round((q - lb) * 16); if (oz === 16) { lb++; oz = 0; } return oz ? `${lb ? lb + ' lb ' : ''}${oz} oz` : lb + ' lb'; } + if ((unit === 'gallon' || unit === 'quart') && q % 1) { // a fraction of a gallon or quart is easier to picture in smaller measures: "3 qt 2 1/2 cups" + let cups = Math.round(q * (unit === 'gallon' ? 16 : 4) * 4) / 4; const parts = []; if (unit === 'gallon') { const g = Math.floor(cups / 16); if (g) parts.push(g + ' gal'); cups -= g * 16; } const qt = Math.floor(cups / 4); if (qt) parts.push(qt + ' qt'); cups -= qt * 4; + if (cups) { const w = Math.floor(cups), f = { 0.25: '1/4', 0.5: '1/2', 0.75: '3/4' }[cups - w]; parts.push(`${w ? w + ' ' : ''}${f || ''}`.trim() + (cups > 1 ? ' cups' : ' cup')); } return parts.join(' ') || '0'; + } + const frac = q % 1, whole = Math.floor(q), nice = { 0.25: '1/4', 0.33: '1/3', 0.5: '1/2', 0.67: '2/3', 0.75: '3/4' }[r2(frac)]; + const num = ['cup', 'tbsp', 'tsp', 'quart', 'pint', 'gallon'].includes(unit) && nice ? `${whole ? whole + ' ' : ''}${nice}` : String(q); + return unit ? `${num} ${unit === 'cup' && q > 1 ? 'cups' : unit}` : num; +} +const aboutWords = (a, b) => { const ws = new Set(words(a)); return words(b).length > 0 && words(b).every(w => ws.has(w)); }; +/** What cooking `ingredients` (lines, or { amount, unit, name }) takes out of the pantry `items` (each { id, name, qty, unit }), times `scale`. + * Returns [{ line, item, take (in the item's own unit), unit, left, ok, why }]. Lines that match nothing in the pantry are not listed. */ +function plan(ingredients, items, scale = 1) { + const out = [], left = new Map(items.map(x => [x.id, x.qty])); + for (const raw of ingredients) { + const ing = typeof raw === 'string' ? { ...parseIngredient(raw), line: raw } : { ...raw, line: raw.line || [raw.amount, raw.unit, raw.name].filter(x => x != null && x !== '').join(' ') }; + let best = null; for (const it of items) if (aboutWords(ing.name, it.name) && (!best || words(it.name).length > words(best.name).length)) best = it; if (!best) continue; + const row = { line: ing.line, item: { id: best.id, name: best.name }, unit: best.unit || '', ok: false, take: 0, left: null, why: '' }; + if (best.qty == null) { row.why = 'not counted'; out.push(row); continue; } + if (ing.amount == null) { row.why = 'no amount in the recipe (“to taste”)'; out.push(row); continue; } + const a = ing.amount * (scale || 1), fromU = ing.unit, toU = best.unit || ''; + let take = fromU === '' && toU === '' ? a : fromU !== '' && toU !== '' ? convert(a, fromU, toU, best.name + ' ' + ing.name) : null; + if (take == null) row.why = fromU === '' ? `the recipe counts ${best.name.toLowerCase()} but the pantry has it in ${toU}` : toU === '' ? `the recipe uses ${fromU} but the pantry counts ${best.name.toLowerCase()}` : `cannot turn ${fromU} into ${toU} for ${best.name.toLowerCase()}`; + else { take = r4(take); const now = left.get(best.id); row.ok = true; row.take = take; row.left = Math.max(0, r4(now - take)); left.set(best.id, row.left); if (take > now) row.why = 'more than the pantry has'; } + out.push(row); + } + return out; +} +module.exports = { UNITS, unitOf, family, density, leadingNumber, parseIngredient, convert, fmt, plan, words };