Meal plan: a shared week plan that fills the shopping list
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b0ca8daa2e
commit
e506c4ce98
3 files changed
+72
-5
No files matched your search
+35
-2
@@ -4,7 +4,7 @@
|
|||||||
const rcApi = (p, opts) => jfetch('/api/recipes' + p, opts);
|
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 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 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 => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
const rcTabs = on => `<div class="bar" style="margin:0 0 12px;justify-content:flex-start"><a class="btn${on === 'all' ? '' : ' ghost'}" href="#/recipes">${icon('chef')}Recipes</a><a class="btn${on === 'plan' ? '' : ' ghost'}" href="#/recipes/plan">${icon('cal')}Meal plan</a><a class="btn${on === 'shop' ? '' : ' ghost'}" href="#/recipes/shop">${icon('check')}Shopping list</a></div>`;
|
||||||
let rcQuery = '', rcPage = 1;
|
let rcQuery = '', rcPage = 1;
|
||||||
|
|
||||||
async function viewRecipes(args = []) {
|
async function viewRecipes(args = []) {
|
||||||
@@ -13,6 +13,7 @@ async function viewRecipes(args = []) {
|
|||||||
try {
|
try {
|
||||||
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
|
||||||
if (args[0] === 'shop') return await rcShop();
|
if (args[0] === 'shop') return await rcShop();
|
||||||
|
if (args[0] === 'plan') return await rcPlan(args[1]);
|
||||||
return await rcHome();
|
return await rcHome();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!location.hash.startsWith('#/recipes')) return;
|
if (!location.hash.startsWith('#/recipes')) return;
|
||||||
@@ -59,7 +60,7 @@ async function rcOne(slug) {
|
|||||||
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
|
<div style="min-width:0"><h1 style="margin:0">${esc(r.name)}</h1>${r.description ? `<p class="sub" style="margin:6px 0 0;white-space:normal">${esc(r.description)}</p>` : ''}
|
||||||
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
|
${facts.length ? `<p class="sub" style="margin:8px 0 0">${facts.map(f => `<b>${esc(f[0])}</b> ${esc(f[1])}`).join(' · ')}</p>` : ''}
|
||||||
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
|
${r.orgUrl ? `<p class="sub" style="margin:4px 0 0"><a href="${esc(r.orgUrl)}" target="_blank" rel="noopener noreferrer" style="color:var(--accent)">Original page</a></p>` : ''}</div></div>
|
||||||
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
<div class="bar" style="justify-content:flex-start"><button class="btn" id="rcadd">${icon('plus')}Add ingredients to shopping list</button><button class="btn ghost" id="rcplan">${icon('cal')}Plan it</button><a class="btn ghost" href="#/recipes/shop">Shopping list</a>${allowed('chat') ? '<button class="btn ghost" id="rcshare">Share to Chat</button>' : ''}</div>
|
||||||
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
|
<div class="rccols"><section><div class="rchead"><h2>Ingredients</h2><button class="btn ghost" id="rcreset" style="min-height:32px">Uncheck all</button></div>
|
||||||
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
|
<div class="list">${r.ingredients.map((i, n) => i.title && !i.text ? `<p class="sub" style="margin:10px 0 2px"><b>${esc(i.title)}</b></p>` : `<label class="row rcing" style="cursor:pointer"><input type="checkbox" data-n="${n}"><span class="grow">${esc(i.text)}</span></label>`).join('') || '<p class="empty">No ingredients.</p>'}</div></section>
|
||||||
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
|
<section><div class="rchead"><h2>Steps</h2></div><ol class="rcsteps">${r.steps.map((s, n) => `<li data-s="${n}">${s.title ? `<b>${esc(s.title)}</b> ` : ''}${esc(s.text).replace(/\n/g, '<br>')}</li>`).join('') || '<li class="empty">No steps.</li>'}</ol></section></div>
|
||||||
@@ -77,6 +78,7 @@ async function rcOne(slug) {
|
|||||||
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
|
$('#rcreset').onclick = () => { done = { i: [], s: [] }; store.del(sk); paint(); };
|
||||||
paint();
|
paint();
|
||||||
if ($('#rcshare')) $('#rcshare').onclick = () => shareToChat(r.name + ' ' + location.origin + '/#/recipes/r/' + encodeURIComponent(slug));
|
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 => {
|
$('#rcadd').onclick = async e => {
|
||||||
const b = e.currentTarget; b.disabled = true;
|
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; }
|
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)
|
try { await rcApi('/shopping/sync', rcOpt('POST', {})); } catch {} // (opening the list also brings Tasks up to date)
|
||||||
await draw(await rcApi('/shopping'));
|
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 = `<div class="wrap"><div class="ptop"><h1>Recipes</h1></div>${rcTabs('plan')}
|
||||||
|
<div class="bar" style="justify-content:space-between;align-items:center"><a class="btn ghost" href="${shift(-7)}" aria-label="Previous week">${icon('back')}</a><b>${days[0].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })} – ${days[6].toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}</b><a class="btn ghost" href="${shift(7)}" aria-label="Next week">${icon('chev')}</a></div>
|
||||||
|
<div class="list">${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 `<div class="row" style="align-items:flex-start${k === today ? ';outline:2px solid var(--accent);outline-offset:-2px;border-radius:12px' : ''}"><span style="width:52px;flex:none"><b>${d.toLocaleDateString(undefined, { weekday: 'short' })}</b><small style="display:block">${d.getDate()}</small></span>
|
||||||
|
<span class="grow">${es.map(e => `<span style="display:flex;align-items:center;gap:6px;margin:2px 0"><a href="#/recipes/r/${encodeURIComponent(e.slug)}" class="grow" style="white-space:normal">${esc(e.name)}${e.meal !== 'dinner' ? ` <small>· ${esc(e.meal)}</small>` : ''}</a><button class="ibtn" data-rm="${e.id}" aria-label="Remove ${esc(e.name)}" title="Remove">${icon('x')}</button></span>`).join('') || '<small>Nothing planned</small>'}</span>
|
||||||
|
<button class="ibtn" data-add="${k}" aria-label="Plan a meal on ${d.toLocaleDateString(undefined, { weekday: 'long' })}" title="Add a meal">${icon('plus')}</button></div>`; }).join('')}</div>
|
||||||
|
<div class="bar"><button class="btn" id="rcpshop"${count ? '' : ' disabled'}>${icon('check')}Add this week's ingredients to the shopping list</button></div></div>`;
|
||||||
|
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', '', `<form id="rcpf">${slug ? '' : '<input type="search" id="rcpq" placeholder="Search your recipes" autocomplete="off" aria-label="Search recipes"><div class="list" id="rcpr" style="margin:8px 0"></div>'}
|
||||||
|
<label for="rcpd">Day</label><input type="date" id="rcpd" value="${esc(date || rcYmd(new Date()))}" required><label for="rcpm">Meal</label><select id="rcpm">${RC_MEALS.map(m => `<option value="${m[0]}"${m[0] === 'dinner' ? ' selected' : ''}>${m[1]}</option>`).join('')}</select><div class="err" id="rcpe"></div>
|
||||||
|
<div class="bar">${slug ? '<button class="btn" style="flex:1;justify-content:center">Add to plan</button>' : ''}</div></form>`);
|
||||||
|
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 => `<a href="#" class="row" data-s="${esc(x.slug)}" data-n="${esc(x.name)}" data-i="${esc(x.id)}"><span class="grow">${esc(x.name)}</span></a>`).join('') || '<div class="row"><span class="grow"><small>No recipes found</small></span></div>'; 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();
|
||||||
|
}
|
||||||
+30
-3
@@ -99,6 +99,19 @@ async function sync() {
|
|||||||
return { ...out, list: tl.id };
|
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) {
|
async function handle(req, res) {
|
||||||
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams;
|
const u = new URL(req.url, 'http://x'), p = u.pathname.slice('/api/recipes'.length) || '/', q = u.searchParams;
|
||||||
try {
|
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' && 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
|
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));
|
const b = JSON.parse(await readBody(req, 1e4) || '{}'), r = await addRecipe(b.slug, +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 s = await sync(); return json(res, 200, { ok: true, ...s, name: r.name });
|
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') {
|
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 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 });
|
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 });
|
||||||
|
|||||||
@@ -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');
|
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"
|
// 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'));
|
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
|
// 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 = '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);
|
process.env.MEALIE_TOKEN = ''; assert.equal((await get('/')).status, 503);
|
||||||
|
|||||||
Reference in new issue
Block a user