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 ? `
` : '';
/** 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 = `
`); 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) {
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.
'; 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 = `