'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 => `
${icon('chef')}Recipes${icon('cal')}Meal plan${icon('list')}Pantry${icon('check')}Shopping list
`; let rcQuery = '', rcPage = 1; async function viewRecipes(args = []) { const v = $('#view'); v.innerHTML = '

Recipes

Loading…

'; 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)}

`; // (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

${rcTabs('all')}
`; 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', `
`); 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 = `
${icon('back')}Recipes
${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

` : ''}
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) => `
  1. ${s.title ? `${esc(s.title)} ` : ''}${esc(s.text).replace(/\n/g, '
    ')}
  2. `).join('') || '
  3. No steps.
  4. '}
${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 => ``; $('#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('')}
` : ''}

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 `
${d.toLocaleDateString(undefined, { weekday: 'short' })}${d.getDate()} ${es.map(e => `${esc(e.name)}${e.meal !== 'dinner' ? ` · ${esc(e.meal)}` : ''}`).join('') || 'Nothing planned'}
`; }).join('')}
`; 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', '', `
${slug ? '' : '
'}
${slug ? '' : ''}
`); 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', '

Working it out…

'); 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) => ``).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 ? '' : ''}
`; 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); }