'use strict';
// ---- Recipes: your Mealie recipes and shopping list, through the hub (the server holds the Mealie token). A recipe's ingredients go on one shopping list, which is
// also kept in Tasks (a "Shopping" list there), so it can be ticked off from either place.
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 => `
`;
let rcQuery = '', rcPage = 1;
async function viewRecipes(args = []) {
const v = $('#view');
v.innerHTML = '';
try {
if (args[0] === 'r' && args[1]) return await rcOne(decodeURIComponent(args[1]));
if (args[0] === 'pantry') return await viewPantry(args[1]);
if (ME && ME.kid && ME.kid.on && !args[0]) return await viewPantry('take'); // children see the pantry to take from
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;
v.innerHTML = `Recipes ${rcTabs('')}
${esc(e.message)}
Try again
`; // (the Pantry does not need Mealie, so its tab is here even when recipes are not set up)
$('#rcretry').onclick = () => route();
}
}
async function rcHome() {
const draw = (items, more, page) => {
const g = $('#rcgrid'); if (!g) return;
const html = items.map(r => `${r.image ? ` ` : ''}
${esc(r.name)} ${esc([rcTime(r.minutes), r.rating ? '★'.repeat(Math.round(r.rating)) : ''].filter(Boolean).join(' · '))} `).join('');
if (page > 1) g.insertAdjacentHTML('beforeend', html); else g.innerHTML = html || 'No recipes found.
';
const mb = $('#rcmore'); if (mb) mb.hidden = !more;
};
const first = await rcApi('/?page=1&q=' + encodeURIComponent(rcQuery)); if (!location.hash.startsWith('#/recipes')) return;
$('#view').innerHTML = `
Recipes ${icon('plus')}Import from a link ${rcTabs('all')}
Show more
`;
rcPage = 1; draw(first.items, first.more, 1);
let timer, seq = 0;
const load = async page => { const my = ++seq; try { const j = await rcApi('/?page=' + page + '&q=' + encodeURIComponent(rcQuery)); if (my === seq) { rcPage = page; draw(j.items, j.more, page); } } catch (e) { toast(e.message); } };
$('#rcq').oninput = e => { clearTimeout(timer); timer = setTimeout(() => { rcQuery = e.target.value.trim(); load(1); }, 350); };
$('#rcmore').onclick = () => load(rcPage + 1);
$('#rcimp').onclick = () => {
const el = openSheet('Import a recipe', 'Mealie reads the recipe from the web page', `
Cancel Import
`);
el.querySelector('#rcx').onclick = closeSheet; const go = el.querySelector('#rcgo'), err = el.querySelector('#rce'), inp = el.querySelector('#rcurl'); inp.focus();
const run = async () => {
err.textContent = ''; go.disabled = true; go.textContent = 'Importing…';
try { const r = await rcApi('/import', rcOpt('POST', { url: inp.value.trim() })); closeSheet(); toast('Imported'); location.hash = '#/recipes/r/' + encodeURIComponent(r.slug); }
catch (e) { err.textContent = e.message; go.disabled = false; go.textContent = 'Import'; }
};
go.onclick = run; inp.onkeydown = e => { if (e.key === 'Enter') run(); };
};
}
async function rcOne(slug) {
const r = await rcApi('/r/' + encodeURIComponent(slug)); if (!location.hash.startsWith('#/recipes')) return;
const facts = [['Prep', rcTime(r.prep)], ['Cook', rcTime(r.cook)], ['Total', rcTime(r.total)], ['Serves', r.servings]].filter(f => f[1]);
$('#view').innerHTML = `
${r.image ? `
` : ''}
${esc(r.name)} ${r.description ? `
${esc(r.description)}
` : ''}
${facts.length ? `
${facts.map(f => `${esc(f[0])} ${esc(f[1])}`).join(' · ')}
` : ''}
${r.orgUrl ? `
Original page
` : ''}
${icon('plus')}Add ingredients to shopping list ${icon('cal')}Plan it ${icon('check')}Cooked it Shopping list ${allowed('chat') ? '
Share to Chat ' : ''}
Ingredients Uncheck all
${r.ingredients.map((i, n) => i.title && !i.text ? `
${esc(i.title)}
` : `
${esc(i.text)} `).join('') || '
No ingredients.
'}
Steps ${r.steps.map((s, n) => `${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, ' ')} `).join('') || 'No steps. '}
${r.notes.length ? `
Notes ${r.notes.map(n => `
${n.title ? `${esc(n.title)} ` : ''}${esc(n.text).replace(/\n/g, ' ')}
`).join('')}` : ''}
`;
// What you have used and done is remembered on this device for each recipe, so you can leave and come back while cooking.
const sk = 'rc_done:' + r.id; let done = store.get(sk, { i: [], s: [] }); if (!done || !Array.isArray(done.i)) done = { i: [], s: [] };
const paint = () => {
document.querySelectorAll('.rcing input').forEach(c => { const on = done.i.includes(+c.dataset.n); c.checked = on; c.nextElementSibling.style.cssText = on ? 'text-decoration:line-through;color:var(--muted)' : ''; });
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.classList.toggle('done', done.s.includes(+li.dataset.s)));
$('#rcreset').hidden = !done.i.length && !done.s.length;
};
const flip = (list, n) => { const k = done[list].indexOf(n); if (k < 0) done[list].push(n); else done[list].splice(k, 1); if (done.i.length || done.s.length) store.set(sk, done); else store.del(sk); paint(); };
document.querySelectorAll('.rcing input').forEach(c => c.onchange = () => flip('i', +c.dataset.n));
document.querySelectorAll('.rcsteps li[data-s]').forEach(li => li.onclick = () => flip('s', +li.dataset.s));
$('#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); $('#rccook').onclick = () => rcCook(r.slug, r.name);
$('#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; }
catch (err) { toast(err.message); b.disabled = false; }
};
}
async function rcShop() {
const draw = async l => {
if (!location.hash.startsWith('#/recipes/shop')) return;
const open = l.items.filter(i => !i.checked), done = l.items.filter(i => i.checked), row = i => `${esc(i.text)} `;
$('#view').innerHTML = `
Shopping list ${rcTabs('shop')}
${open.map(row).join('') || '
Nothing to buy. Open a recipe and add its ingredients.
'}
${done.length ? `
Got it ${done.map(row).join('')}
Remove ticked items
` : ''}
Also in Tasks as the “Shopping” list: ticking an item there ticks it here.
`;
document.querySelectorAll('.rcitem input').forEach(c => c.onchange = async () => {
c.disabled = true; try { await rcApi('/shopping/check', rcOpt('POST', { id: c.dataset.id, checked: c.checked })); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); c.checked = !c.checked; c.disabled = false; }
});
$('#rcnew').onsubmit = async e => { e.preventDefault(); const t = $('#rctext').value.trim(); if (!t) return; try { await rcApi('/shopping/add', rcOpt('POST', { text: t })); draw(await rcApi('/shopping')); } catch (err) { toast(err.message); } };
if ($('#rcclear')) $('#rcclear').onclick = async () => { try { await rcApi('/shopping/clear', rcOpt('POST', {})); draw(await rcApi('/shopping')); } catch (e) { toast(e.message); } };
};
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('')}
${icon('check')}Add this week's ingredients to the shopping list Pantry
`;
document.querySelectorAll('[data-add]').forEach(b => b.onclick = () => rcPlanPick(null, '', '', b.dataset.add));
document.querySelectorAll('[data-cook]').forEach(b => b.onclick = () => { const i = b.dataset.cook.indexOf('|'); rcCook(b.dataset.cook.slice(0, i), b.dataset.cook.slice(i + 1)); });
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); } });
$('#rcpantry').onclick = () => { location.hash = '#/recipes/pantry'; };
if (count) rcApi(`/plan/pantry?from=${from}&to=${to}`).then(r => { const box = $('#rcphave'); if (!box || !r.items.length) return; const names = [...new Set(r.items.map(i => i.have))]; box.innerHTML = `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.
`; }).catch(() => {});
$('#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();
}
/** "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', '');
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 = `${[[0.5, 'Half'], [1, 'As written'], [2, 'Double'], [3, 'Triple']].map(([v, l]) => `
${l} `).join('')}
${ok.length ? `
${ok.map((x, i) => `${esc(x.item.name)} −${esc(x.takeLabel)}${esc(x.line)} · ${x.left === 0 ? 'none' : esc(x.leftLabel || x.left)} left${x.why ? ' · ' + esc(x.why) : ''} `).join('')}
` : '
Nothing in your pantry matches this recipe\'s ingredients that can be counted off.
'}
${skip.length ? `
Left alone ${skip.map(x => `
${esc(x.item.name)} ${esc(x.line)} · ${esc(x.why)}
`).join('')}
` : ''}
These are estimates (a cup of flour is about 4¼ oz). Untick anything that is not right.
Pantry ${ok.length ? '
Take these out ' : ''}
`;
el.querySelector('#rcck').outerHTML = body;
el.querySelectorAll('[data-sc]').forEach(a => a.onclick = e => { e.preventDefault(); show(+a.dataset.sc); });
const go = el.querySelector('#rccgo'); if (go) go.onclick = async () => { const picks = [...el.querySelectorAll('[data-i]:checked')].map(c => ok[+c.dataset.i]); if (!picks.length) return closeSheet();
try { const t = await jfetch('/api/pantry/take', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ takes: picks.map(x => ({ id: x.item.id, amount: x.take, unit: x.unit })), note: 'cooked ' + name }) }); closeSheet(); const low = t.done.filter(d => d.isLow).map(d => d.name); toast(`Took ${t.done.length} item${t.done.length === 1 ? '' : 's'} out of the pantry${low.length ? '. Running low: ' + low.join(', ') : ''}`, low.length ? { label: 'Pantry', run: () => { location.hash = '#/recipes/pantry'; } } : undefined); }
catch (e) { el.querySelector('#rcce').textContent = e.message; } };
};
show(scale);
}