Pantry units and recipes: amounts in pounds, cups and so on, Use some, and Cooked it with a preview of what comes out

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
nmemmertandClaude Sonnet 5.5 committed 2026-10-27 12:00:11 -04:00
1 parent ac55520a8c
commit c236120a72
11 files changed
+204 -21

No files matched your search

+24 -7
View File
@@ -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');