Meal Planner – Weekly Schedule & Grocery Lists

The app reads recepies.xlsx (Ingredients, Contents, USERS) and uses the weekly schedule to generate grocery lists and meal details.

Status:
Loading recepies.xlsx…
Recipes
Weekly schedule
16 fixed slots. Use Shop # buttons to split shopping trips. Drag a row onto another to swap meals. On mobile, the table scrolls horizontally so the Meal column stays available.
Grocery lists by shop #
Fill the schedule to generate grocery lists.
Meals with ingredients (aggregated by recipe & shop #)
No meals selected yet.
`; } function derivePlanNameFromFilename(filename) { const name = String(filename || '').replace(/\.html$/i, ''); if (/^\d{4}-\d{2}-\d{2}-/.test(name)) return name.slice(11); return name; } function getRecipeInfoHtml(rec, includeActions = false) { const bits = []; if (rec.nutrition?.kcal != null) bits.push(`Kcal: ${rec.nutrition.kcal}`); if (rec.nutrition?.protein != null) bits.push(`Protein: ${rec.nutrition.protein} g`); if (rec.nutrition?.fat != null) bits.push(`Fat: ${rec.nutrition.fat} g`); if (rec.nutrition?.carbs != null) bits.push(`Carbs: ${rec.nutrition.carbs} g`); let html = `
${bits.length ? bits.join(' · ') : 'No nutrition data.'}

Base portions: ${rec.basePortions || '?'}

`; if (!rec.ingredients.length) html += `

No ingredients found.

`; else html += `${rec.ingredients.map(ing => ``).join('')}
CategoryIngredientQtyUnit
${escapeHtml(ing.category)}${escapeHtml(ing.ingredient)}${ing.qty}${escapeHtml(ing.unit)}
`; if (includeActions) html += ``; return html; } function renderRecipeList() { recipesListEl.innerHTML = ''; let names = Array.from(recipesByName.keys()); if (recipeSearch) names = names.filter(n => n.toLowerCase().includes(recipeSearch)); names.sort((a,b)=>a.localeCompare(b,'bg')); if (!names.length) { recipesListEl.innerHTML = 'No recipes match the search.'; return; } names.forEach(name => { const rec = recipesByName.get(name); const row = document.createElement('div'); row.className='recipe-row'; const btn=document.createElement('button'); btn.type='button'; btn.className='recipe-btn'; btn.textContent=rec.pageNumber?`${name} (p. ${rec.pageNumber})`:name; btn.addEventListener('click',()=>openRecipeModal(name)); const addBtn=document.createElement('button'); addBtn.type='button'; addBtn.className='recipe-add'; addBtn.textContent='+'; addBtn.addEventListener('click',e=>{e.stopPropagation(); addRecipeToFirstEmptySlot(name,0);}); row.appendChild(btn); row.appendChild(addBtn); recipesListEl.appendChild(row); }); } function renderRecipeOptionsDatalist() { let dl=document.getElementById('recipe-options'); if(!dl){ dl=document.createElement('datalist'); dl.id='recipe-options'; document.body.appendChild(dl);} dl.innerHTML=''; Array.from(recipesByName.keys()).sort((a,b)=>a.localeCompare(b,'bg')).forEach(name=>{ const opt=document.createElement('option'); opt.value=name; dl.appendChild(opt); }); } function renderSchedule() { const table=document.createElement('table'); const thead=document.createElement('thead'); const tbody=document.createElement('tbody'); thead.innerHTML=`SlotMeal (recipe)PortionsShop #`; scheduleState.forEach(row=>{ const tr=document.createElement('tr'); tr.setAttribute('data-group',row.shoppingGroup); tr.setAttribute('data-row-id',row.id); tr.setAttribute('draggable','true'); tr.innerHTML=`${row.day} – ${row.meal}
${row.portions||0}
`; tbody.appendChild(tr); }); table.appendChild(thead); table.appendChild(tbody); scheduleContainer.innerHTML=''; scheduleContainer.appendChild(table); scheduleContainer.querySelectorAll('.inp-meal').forEach(inp=>inp.addEventListener('change',()=>{ const id=Number(inp.getAttribute('data-row-id')); const row=scheduleState.find(r=>r.id===id); if(!row)return; const val=inp.value.trim(); row.recipeName=val; renderGroceryLists(); renderMealList(); })); scheduleContainer.querySelectorAll('.spinner').forEach(spinner=>{ const id=Number(spinner.getAttribute('data-row-id')); const row=scheduleState.find(r=>r.id===id); if(!row)return; const valueEl=spinner.querySelector('.spin-value'); spinner.querySelector('.spin-dec').addEventListener('click',()=>{ row.portions=Math.max(0,(row.portions||0)-1); valueEl.textContent=row.portions; renderGroceryLists(); renderMealList(); }); spinner.querySelector('.spin-inc').addEventListener('click',()=>{ row.portions=Math.min(9,(row.portions||0)+1); valueEl.textContent=row.portions; renderGroceryLists(); renderMealList(); }); }); scheduleContainer.querySelectorAll('.group-buttons').forEach(div=>div.addEventListener('click',e=>{ const btn=e.target.closest('.btn-group'); if(!btn)return; const rowId=Number(div.getAttribute('data-row-id')); const row=scheduleState.find(r=>r.id===rowId); if(!row)return; row.shoppingGroup=Number(btn.getAttribute('data-group'))||1; renderSchedule(); renderGroceryLists(); renderMealList(); })); scheduleContainer.querySelectorAll('tbody tr').forEach(tr=>{ tr.addEventListener('dragstart',e=>e.dataTransfer.setData('text/plain',tr.getAttribute('data-row-id'))); tr.addEventListener('dragover',e=>e.preventDefault()); tr.addEventListener('drop',e=>{ e.preventDefault(); const sourceId=Number(e.dataTransfer.getData('text/plain')); const targetId=Number(tr.getAttribute('data-row-id')); if(!sourceId||!targetId||sourceId===targetId)return; swapScheduleRows(sourceId,targetId); }); }); } function swapScheduleRows(idA,idB){ const rowA=scheduleState.find(r=>r.id===idA), rowB=scheduleState.find(r=>r.id===idB); if(!rowA||!rowB)return; const tmp={recipeName:rowA.recipeName,portions:rowA.portions,shoppingGroup:rowA.shoppingGroup}; rowA.recipeName=rowB.recipeName; rowA.portions=rowB.portions; rowA.shoppingGroup=rowB.shoppingGroup; rowB.recipeName=tmp.recipeName; rowB.portions=tmp.portions; rowB.shoppingGroup=tmp.shoppingGroup; renderSchedule(); renderGroceryLists(); renderMealList(); } function addRecipeToFirstEmptySlot(recipeName, portions=0){ const slot=scheduleState.find(r=>!r.recipeName); if(!slot){ alert('No empty slots left in the schedule.'); return; } slot.recipeName=recipeName; slot.portions=Math.max(0,Math.min(9,Number(portions)||0)); renderSchedule(); renderGroceryLists(); renderMealList(); } function renderGroceryLists(){ groceryListEl.innerHTML=''; const groups=[...new Set(scheduleState.filter(r=>r.recipeName&&recipesByName.has(r.recipeName)&&r.portions>0).map(r=>r.shoppingGroup))].sort((a,b)=>a-b); if(!groups.length){ groceryListEl.innerHTML='No valid meals with portions in the schedule.'; return; } groups.forEach(g=>{ const agg=new Map(); scheduleState.forEach(row=>{ if(row.shoppingGroup!==g||!row.recipeName||!recipesByName.has(row.recipeName)||row.portions<=0)return; const rec=recipesByName.get(row.recipeName); const base=rec.basePortions||1; const factor=base?row.portions/base:1; rec.ingredients.forEach(ing=>{ const key=`${ing.category}||${ing.ingredient}||${ing.unit}`; if(!agg.has(key)) agg.set(key,{category:ing.category, ingredient:ing.ingredient, unit:ing.unit, qty:0}); agg.get(key).qty += ing.qty*factor; }); }); const wrap=document.createElement('div'); wrap.style.marginTop='0.35rem'; wrap.innerHTML=`Shopping list #${g}`; groceryListEl.appendChild(wrap); const items=Array.from(agg.values()).sort((a,b)=>a.category.localeCompare(b.category,'bg')||a.ingredient.localeCompare(b.ingredient,'bg')); const table=document.createElement('table'); table.innerHTML=`CategoryIngredientTotal QtyUnit${items.map(it=>`${escapeHtml(it.category)}${escapeHtml(it.ingredient)}${formatQty(it.qty)}${escapeHtml(it.unit)}`).join('')}`; groceryListEl.appendChild(table); }); } function renderMealList(){ mealListEl.innerHTML=''; const aggregates=new Map(); scheduleState.forEach((row,index)=>{ if(!row.recipeName||!recipesByName.has(row.recipeName)||row.portions<=0)return; const rec=recipesByName.get(row.recipeName); const base=rec.basePortions||1; const factor=base?row.portions/base:1; const key=`${row.shoppingGroup}||${row.recipeName}`; if(!aggregates.has(key)) aggregates.set(key,{shoppingGroup:row.shoppingGroup, recipeName:row.recipeName, pageNumber:rec.pageNumber, nutrition:rec.nutrition, totalPortions:0, orderIndex:index, ingredients:new Map()}); const agg=aggregates.get(key); agg.totalPortions += row.portions; rec.ingredients.forEach(ing=>{ const ikey=`${ing.category}||${ing.ingredient}||${ing.unit}`; if(!agg.ingredients.has(ikey)) agg.ingredients.set(ikey,{category:ing.category, ingredient:ing.ingredient, unit:ing.unit, qty:0}); agg.ingredients.get(ikey).qty += ing.qty*factor; }); }); const list=Array.from(aggregates.values()).sort((a,b)=>(a.shoppingGroup-b.shoppingGroup)||(a.orderIndex-b.orderIndex)); if(!list.length){ mealListEl.innerHTML='No meals selected yet.'; return; } list.forEach(agg=>{ const block=document.createElement('div'); block.className='meal-block'; const pagePart=agg.pageNumber?` (p. ${escapeHtml(agg.pageNumber)})`:''; const ingList=Array.from(agg.ingredients.values()).sort((a,b)=>a.category.localeCompare(b.category,'bg')||a.ingredient.localeCompare(b.ingredient,'bg')); block.innerHTML=`

Shop #${agg.shoppingGroup} – ${escapeHtml(agg.recipeName)}${pagePart} — total portions: ${agg.totalPortions}

${ingList.map(it=>``).join('')}
CategoryIngredientQty (aggregated)Unit
${escapeHtml(it.category)}${escapeHtml(it.ingredient)}${formatQty(it.qty)}${escapeHtml(it.unit)}
`; mealListEl.appendChild(block); }); } function formatQty(n){ return Number(n).toFixed(2).replace(/\.00$/,'').replace(/(\.\d)0$/,'$1'); } function slugify(v){ return String(v||'').trim().toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'') || 'meal-plan'; } function escapeHtml(v){ return String(v ?? '').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",'''); } function escapeAttr(v){ return escapeHtml(v); }