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

+10
View File
@@ -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']);
+5
View File
@@ -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);
+29
View File
@@ -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');