diff --git a/public/recipes-view.js b/public/recipes-view.js
index 14d7da2..08a57fc 100644
--- a/public/recipes-view.js
+++ b/public/recipes-view.js
@@ -4,7 +4,7 @@
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
const rcOpt = (m, o) => ({ method: m, headers: { 'content-type': 'application/json' }, body: o ? JSON.stringify(o) : undefined });
const rcTime = m => { m = +m || 0; if (!m) return ''; const h = Math.floor(m / 60); return h ? h + ' h' + (m % 60 ? ' ' + (m % 60) + ' min' : '') : m + ' min'; };
-const rcTabs = on => `
`;
+const rcTabs = on => ``;
let rcQuery = '', rcPage = 1;
async function viewRecipes(args = []) {
@@ -13,6 +13,7 @@ async function viewRecipes(args = []) {
try {
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
if (args[0] === 'shop') return await rcShop();
+ if (args[0] === 'plan') return await rcPlan(args[1]);
return await rcHome();
} catch (e) {
if (!location.hash.startsWith('#/recipes')) return;
@@ -59,7 +60,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) => `- ${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, '
')} `).join('') || '- No steps.
'}
@@ -77,6 +78,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);
$('#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; }
@@ -102,3 +104,34 @@ async function rcShop() {
try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date)
await draw(await rcApi('/shopping'));
}
+
+// ---- Meal plan: a week at a time, shared by the family. Pick a recipe for a day, then add the week's ingredients to the shopping list in one tap.
+const rcYmd = d => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
+const rcMonday = d => { const x = new Date(d.getFullYear(), d.getMonth(), d.getDate()); x.setDate(x.getDate() - ((x.getDay() + 6) % 7)); return x; };
+const RC_MEALS = [['breakfast', 'Breakfast'], ['lunch', 'Lunch'], ['dinner', 'Dinner']];
+async function rcPlan(start) {
+ const mon = start && /^\d{4}-\d{2}-\d{2}$/.test(start) ? rcMonday(new Date(start + 'T12:00:00')) : rcMonday(new Date()), days = [...Array(7)].map((_, i) => { const d = new Date(mon); d.setDate(d.getDate() + i); return d; });
+ const from = rcYmd(days[0]), to = rcYmd(days[6]), plan = await rcApi(`/plan?from=${from}&to=${to}`); if (!location.hash.startsWith('#/recipes')) return;
+ const shift = n => { const d = new Date(mon); d.setDate(d.getDate() + n); return '#/recipes/plan/' + rcYmd(d); }, today = rcYmd(new Date()), count = Object.values(plan).reduce((n, l) => n + l.length, 0);
+ $('#view').innerHTML = `Recipes
${rcTabs('plan')}
+
${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 `
`; }).join('')}
+
`;
+ document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
+ 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); } });
+ $('#rcpshop').onclick = async e => { const b = e.currentTarget; b.disabled = true; try { const o = await rcApi('/plan/shop', rcOpt('POST', { from, to })); toast(`Ingredients for ${o.recipes} recipe${o.recipes === 1 ? '' : 's'} added`, { label: 'Open', run: () => { location.hash = '#/recipes/shop'; } }); } catch (er) { toast(er.message); } b.disabled = false; };
+}
+/** Choose the day and meal for a recipe (from a recipe page), or the recipe for a day (from the plan). */
+function rcPlanPick(slug, name, rid, date) {
+ const el = openSheet(slug ? 'Plan ' + name : 'Add a meal', '', ``);
+ const save = async (sl, nm, id) => { try { await rcApi('/plan/add', rcOpt('POST', { date: el.querySelector('#rcpd').value, slug: sl, name: nm, rid: id, meal: el.querySelector('#rcpm').value })); closeSheet(); toast('Added to the meal plan', { label: 'Open', run: () => { location.hash = '#/recipes/plan/' + el.querySelector('#rcpd').value; } }); if (location.hash.startsWith('#/recipes/plan')) rcPlan(el.querySelector('#rcpd').value); } catch (e) { el.querySelector('#rcpe').textContent = e.message; } };
+ el.querySelector('#rcpf').onsubmit = e => { e.preventDefault(); if (slug) save(slug, name, rid); };
+ if (slug) return;
+ 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();
+}
diff --git a/recipes.js b/recipes.js
index 7c0584d..36aed8e 100644
--- a/recipes.js
+++ b/recipes.js
@@ -99,6 +99,19 @@ async function sync() {
return { ...out, list: tl.id };
}
+// ---- Meal plan: which recipe is on which day (shared by the whole family), kept in DATA_DIR/mealplan.json
+const DAY = /^\d{4}-\d{2}-\d{2}$/, MEALS = ['breakfast', 'lunch', 'dinner'], planFile = () => path.join(process.env.DATA_DIR || path.join(__dirname, 'data'), 'mealplan.json');
+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; };
+/** 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));
+ try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); }
+ catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes)
+ return r;
+}
+
async function handle(req, res) {
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams;
try {
@@ -127,11 +140,25 @@ async function handle(req, res) {
}
if (p === '/shopping' && req.method === 'GET') return json(res, 200, await readList());
if (p === '/shopping/add-recipe' && req.method === 'POST') { // the recipe's ingredients go on the list; Mealie adds up what is already there
- const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await mealie('/api/recipes/' + (slugOk(b.slug) ? b.slug : '-')), list = await mainList(), mult = Math.max(0.25, Math.min(20, +b.scale || 1));
- try { await shop(`/lists/${list.id}/recipe/${r.id}`, { method: 'POST', body: { recipeIncrementQuantity: mult } }); }
- catch (e) { if (![404, 405, 422].includes(e.status)) throw e; await shop(`/lists/${list.id}/recipe`, { method: 'POST', body: [{ recipeId: r.id, recipeIncrementQuantity: mult }] }); } // (newer Mealie takes a list of recipes)
+ const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await addRecipe(b.slug, +b.scale || 1);
const s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name });
}
+ if (p === '/plan' && req.method === 'GET') { const from = q.get('from') || '', to = q.get('to') || ''; if (!DAY.test(from) || !DAY.test(to)) throw fail(400, 'Bad dates'); return json(res, 200, planBetween(from, to)); }
+ if (p === '/plan/add' && req.method === 'POST') {
+ const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!DAY.test(b.date || '') || !slugOk(b.slug)) throw fail(400, 'Pick a day and a recipe');
+ const d = readPlan(), id = crypto.randomBytes(5).toString('hex'), cut = new Date(Date.now() - 90 * 864e5).toISOString().slice(0, 10);
+ for (const k of Object.keys(d.days)) if (k < cut) delete d.days[k];
+ (d.days[b.date] = d.days[b.date] || []).push({ id, slug: b.slug, name: String(b.name || b.slug).slice(0, 160), rid: idOk(b.rid) ? b.rid : '', meal: MEALS.includes(b.meal) ? b.meal : 'dinner', by: (users.current() || {}).id || '' });
+ 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 === '/plan/shop' && req.method === 'POST') { // every planned meal in the range goes on the shopping list (a recipe planned twice counts twice)
+ const b = JSON.parse(await readBody(req, 1e4) || '{}'); if (!DAY.test(b.from || '') || !DAY.test(b.to || '')) throw fail(400, 'Bad dates');
+ const count = {}; for (const es of Object.values(planBetween(b.from, b.to))) for (const e of es) count[e.slug] = (count[e.slug] || 0) + 1;
+ const slugs = Object.keys(count); if (!slugs.length) throw fail(400, 'Nothing is planned in those days'); let added = 0;
+ for (const sl of slugs) { try { await addRecipe(sl, count[sl]); added++; } catch (e) { if (e.code !== 404 && e.status !== 404) throw e; } }
+ const s = await sync(); return json(res, 200, { ok: true, ...s, recipes: added });
+ }
if (p === '/shopping/add' && req.method === 'POST') {
const b = JSON.parse(await readBody(req, 1e4) || '{}'), text = String(b.text || '').trim().slice(0, 200); if (!text) throw fail(400, 'Type what to buy');
const list = await mainList(); await shop('/items', { method: 'POST', body: { shoppingListId: list.id, note: text, quantity: 0, checked: false } }); rememberWho(text, users.current().name); await sync(); return json(res, 200, { ok: true });
diff --git a/tools/recipes-test.js b/tools/recipes-test.js
index c624fc4..e361690 100644
--- a/tools/recipes-test.js
+++ b/tools/recipes-test.js
@@ -55,6 +55,13 @@ const fake = http.createServer((req, res) => {
j = await (await post('/shopping/clear')).json(); assert.ok(j.removed >= 2); assert.ok(!items.some(i => i.checked)); assert.ok(!tasks().tasks.some(x => x.title === 'butter'), 'removed items leave Tasks too');
// an older Mealie that only knows the list form of "add recipe"
newApi = false; await post('/shopping/add-recipe', { slug: 'bread' }); assert.ok(seen.includes('POST /api/households/shopping/lists/1111aaaa-1/recipe'));
+ // meal plan: shared, by day; the week's ingredients go on the shopping list
+ assert.equal((await post('/plan/add', { date: 'soon', slug: 'bread' })).status, 400, 'a real day is needed');
+ await post('/plan/add', { date: '2027-03-02', slug: 'bread', name: 'Bread', meal: 'dinner' }); await post('/plan/add', { date: '2027-03-04', slug: 'bread', name: 'Bread', meal: 'lunch' }); await post('/plan/add', { date: '2027-03-20', slug: 'bread', name: 'Bread' });
+ j = await (await get('/plan?from=2027-03-02&to=2027-03-08')).json(); assert.deepEqual(Object.keys(j), ['2027-03-02', '2027-03-04'], 'only the days asked for'); assert.equal(j['2027-03-04'][0].meal, 'lunch');
+ j = await (await post('/plan/shop', { from: '2027-03-02', to: '2027-03-08' })).json(); assert.equal(j.ok, true); assert.equal(j.recipes, 1, 'a recipe planned twice is added once, twice over');
+ assert.equal((await post('/plan/shop', { from: '2027-04-01', to: '2027-04-07' })).status, 400, 'nothing planned, nothing to add');
+ await post('/plan/remove', { id: (await (await get('/plan?from=2027-03-02&to=2027-03-02')).json())['2027-03-02'][0].id }); assert.deepEqual(await (await get('/plan?from=2027-03-02&to=2027-03-02')).json(), {});
// 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);