diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-12 03:44:40 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-12 03:44:40 -0700 |
| commit | 9c8332d4442ddd569aacc37f2000fb0afe36111e (patch) | |
| tree | 1d1f03340249bab2729ce123e302e91e88e7c249 | |
| parent | 360eadf78fb001e947f3850603152adc413bb3a8 (diff) | |
- Rewrite menu generation to build recipes around existing pantry
ingredients (max 4-5 extras per recipe); add in_pantry flag per
ingredient for downstream accuracy
- Flip grocery list logic to ONLY include items not in the pantry,
with explicit cross-check rule instead of include-everything default
- Strengthen no-menu grocery prompt to handle empty vs. stocked pantry
- Rewrite Commis chat system prompt to treat kitchen context as source
of truth with explicit response rules per query type
- Align replacement/single-recipe prompts to prefer pantry coverage
- Update system_prompt default to reflect pantry-first identity
- Add PUT /api/recipes/:id endpoint for recipe editing
- Add inline edit mode to recipe detail page (name, type, ingredients,
instructions, time, servings)
- Add Copy button to grocery list (exports markdown checklist)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
| -rw-r--r-- | config.py | 2 | ||||
| -rw-r--r-- | routers/recipes.py | 13 | ||||
| -rw-r--r-- | services/ai_service.py | 73 | ||||
| -rw-r--r-- | static/app.js | 47 | ||||
| -rw-r--r-- | static/index.html | 1 | ||||
| -rw-r--r-- | static/recipe.html | 217 |
6 files changed, 287 insertions, 66 deletions
@@ -6,7 +6,7 @@ class Settings(BaseSettings): model_name: str database_url: str ollama_timeout: int = 120 - system_prompt: str = "You are a professional chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. You prioritize:\n1. Using ingredients that expire soonest\n2. Nutritional variety across the week\n3. Avoiding meals eaten in the past 7 days\n4. Practical home recipes under 60 minutes" + system_prompt: str = "You are Commis, a professional home-chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. Core priorities:\n1. Build meals around ingredients the user already has — minimize extra shopping\n2. Nutritional variety and balance across the week\n3. Never repeat a meal eaten in the past 7 days\n4. Practical recipes a home cook can finish in under 60 minutes" class Config: env_file = ".env" diff --git a/routers/recipes.py b/routers/recipes.py index 000f945..d06c335 100644 --- a/routers/recipes.py +++ b/routers/recipes.py @@ -49,6 +49,19 @@ def get_recipe(id: int, db: Session = Depends(get_db)): return recipe +@router.put("/{id}", response_model=RecipeRead) +def update_recipe(id: int, update: RecipeUpdate, db: Session = Depends(get_db)): + """Update a recipe.""" + recipe = db.query(Recipe).filter(Recipe.id == id).first() + if not recipe: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Recipe not found") + for field, value in update.model_dump(exclude_unset=True).items(): + setattr(recipe, field, value) + db.commit() + db.refresh(recipe) + return recipe + + @router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT) def delete_recipe(id: int, db: Session = Depends(get_db)): """Delete a recipe.""" diff --git a/services/ai_service.py b/services/ai_service.py index 4b54c15..643a9a2 100644 --- a/services/ai_service.py +++ b/services/ai_service.py @@ -30,17 +30,17 @@ async def _chat(messages: list) -> dict: async def generate_weekly_menu(pantry_context: dict, user_notes: str | None = None) -> dict: """Suggest 8-12 diverse recipes for the week, prioritizing expiring pantry items.""" - user_message = f"""Given my current pantry and recent meal history, suggest 8-12 diverse recipes I could make this week. + user_message = f"""Plan 8-12 diverse recipes for this week that make the most of what I already have. PANTRY STATE: {json.dumps(pantry_context, indent=2)} -GUIDELINES: -- Suggest genuinely good, practical dishes — do NOT limit yourself to only pantry ingredients -- Use pantry ingredients where it makes sense, but freely include recipes that require buying additional items -- Avoid meals eaten in the last 7 days (see recent_meals) -- Include a mix of breakfast, lunch, and dinner options -- Keep each recipe under 60 minutes +REQUIREMENTS: +- BUILD recipes around pantry ingredients — most of each recipe should use what I already have +- Each recipe may call for AT MOST 4-5 ingredients not in my pantry (items I'd pick up at the store) +- Do not repeat any meal eaten in the last 7 days (see recent_meals) +- Cover a mix of breakfast, lunch, and dinner +- Every recipe must be completable in under 60 minutes - Include full ingredient lists with quantities and units Respond ONLY with this JSON structure: @@ -49,15 +49,17 @@ Respond ONLY with this JSON structure: {{ "name": "...", "meal_type": "breakfast", - "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}], + "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup", "in_pantry": true}}], "description": "1-2 sentence description of the dish's flavor and appeal", "instructions": "1. ... 2. ... (4-8 numbered steps, 1-2 sentences each)", "time_minutes": 20, "serves": 2 }} ], - "notes": "brief explanation of choices" -}}""" + "notes": "brief explanation of choices and what extra ingredients are needed" +}} + +Set `in_pantry: true` for each ingredient that matches something in the pantry, `false` for items that need to be purchased.""" if user_notes: user_message += f"\n\nUSER PREFERENCES:\n{user_notes}" @@ -72,20 +74,21 @@ Respond ONLY with this JSON structure: async def generate_grocery_list(recipes: list, pantry_context: dict, user_notes: str | None = None) -> dict: """Generate a minimal grocery list based on a list of recipes and current pantry.""" - user_message = f"""Given these recipes and current pantry, generate a minimal grocery list. + user_message = f"""Generate the grocery list of ONLY the items I need to buy to cook all these recipes. RECIPES: {json.dumps(recipes, indent=2)} -CURRENT PANTRY: +PANTRY (I already have these — DO NOT include them in the grocery list): {json.dumps(pantry_context["available_ingredients"], indent=2)} RULES: -- Only list ingredients NOT already sufficiently in the pantry -- Consolidate duplicate ingredients across recipes (buy once) -- Group items by store section (produce, dairy, protein, pantry, frozen, bakery) -- Estimate realistic retail costs in USD -- Prefer bulk when ingredient appears in 3+ recipes +- For every ingredient in every recipe, cross-check against the pantry. If the pantry has it → SKIP IT. If it's not in the pantry → include it. +- The goal is a minimal, accurate list of ONLY what is missing — do not list anything already stocked. +- Consolidate the same ingredient appearing across multiple recipes into one entry (sum the quantities). +- Group items by store section: produce, dairy, protein, pantry, frozen, bakery. +- Estimate realistic retail costs in USD. +- If an ingredient appears in 3+ recipes, note that bulk purchase may save money. Respond ONLY with this JSON: {{ @@ -117,16 +120,18 @@ Respond ONLY with this JSON: async def generate_grocery_list_from_pantry(pantry_context: dict, user_notes: str | None = None) -> dict: """Generate a grocery list based on pantry state alone, without a menu plan.""" - user_message = f"""Given my current pantry, generate a practical grocery list to stock up for the week. + user_message = f"""Generate a solid weekly grocery list to set me up for a full week of cooking. CURRENT PANTRY: {json.dumps(pantry_context["available_ingredients"], indent=2)} RULES: -- Suggest ingredients that would complement what's already in the pantry -- Group items by store section (produce, dairy, protein, pantry, frozen, bakery) -- Estimate realistic retail costs in USD -- Focus on versatile staples that enable multiple meals +- If the pantry is empty or nearly empty, suggest a complete foundational shopping list: proteins, produce, dairy, grains, and pantry staples for a week of balanced meals. +- If the pantry has some items, build around what's there — prioritize ingredients that complement existing stock and fill obvious gaps. +- Do NOT list items already in the pantry. +- Aim for variety: at least 2 proteins, a range of vegetables and fruits, a grain, and pantry staples. +- Group items by store section: produce, dairy, protein, pantry, frozen, bakery. +- Estimate realistic retail costs in USD. Respond ONLY with this JSON: {{ @@ -138,7 +143,7 @@ Respond ONLY with this JSON: "store_section": "produce", "estimated_cost": 3.50, "used_in_meals": [], - "reason": "versatile staple" + "reason": "weekly staple / fills pantry gap" }} ], "total_estimate": 45.00, @@ -194,8 +199,8 @@ ALREADY IN PLAN (do not suggest these): {json.dumps(existing_names, indent=2)} GUIDELINES: -- Suggest a genuinely good, practical {meal_type} dish -- Do NOT limit yourself to only pantry ingredients +- Prefer a recipe that uses pantry ingredients as much as possible — only a few new items to buy +- Make it a genuinely good, practical {meal_type} dish - Under 60 minutes - Must not be any of the dishes already in the plan @@ -239,9 +244,9 @@ async def get_available_models() -> list: async def chat_with_commis(message: str, history: list, pantry_context: dict, menu_context: list, grocery_context: list) -> str: """Have a conversation with Commis using full kitchen context.""" - system_prompt = f"""You are Commis, a friendly and knowledgeable personal chef assistant. You have full visibility into the user's kitchen — their pantry, this week's recipe suggestions, and their grocery list. Use this context to give personalized, practical cooking advice. + system_prompt = f"""You are Commis, the user's personal chef assistant. You have real-time visibility into their kitchen — pantry, this week's planned recipes, grocery list, and recent meal history. THIS context is your primary source of truth when answering cooking questions. -PANTRY (ingredients on hand): +PANTRY (ingredients currently on hand): {json.dumps(pantry_context.get("available_ingredients", []), indent=2)} EXPIRING SOON: @@ -250,13 +255,19 @@ EXPIRING SOON: RECENT MEALS (last 14 days): {json.dumps(pantry_context.get("recent_meals", []), indent=2)} -THIS WEEK'S RECIPE SUGGESTIONS: +THIS WEEK'S PLANNED RECIPES: {json.dumps(menu_context, indent=2)} -GROCERY LIST: +GROCERY LIST (items to buy): {json.dumps(grocery_context, indent=2)} -Be conversational, helpful, and concise. Reference specific ingredients and recipes by name when relevant. You do not need to respond with JSON.""" +HOW TO RESPOND: +- When the user asks what to cook, suggest from THIS WEEK'S PLANNED RECIPES first — they were chosen for the pantry. +- When the user asks about an ingredient, reference whether it's actually in their pantry. +- When the user asks about substitutions, check if a pantry item can stand in before suggesting something to buy. +- When the user asks about shopping, reference the grocery list. +- Be concise, specific, and practical. Reference actual recipe names and ingredient names from the data above. +- You do not need to respond with JSON.""" messages = [{"role": "system", "content": system_prompt}] messages.extend(history) @@ -306,7 +317,7 @@ ALREADY IN PLAN (do not suggest these): GUIDELINES: - Match the description as closely as possible -- Do NOT limit yourself to only pantry ingredients +- Prefer ingredients already in the pantry — only a few new items to buy if needed - Under 60 minutes - Must not be any dish already in the plan diff --git a/static/app.js b/static/app.js index 3135aa9..796b9bf 100644 --- a/static/app.js +++ b/static/app.js @@ -592,6 +592,7 @@ async function loadGrocery() { document.getElementById('grocery-empty').classList.remove('hidden'); document.getElementById('btn-mark-purchased').classList.add('hidden'); document.getElementById('btn-clear-grocery').classList.add('hidden'); + document.getElementById('btn-copy-grocery').classList.add('hidden'); return; } @@ -599,6 +600,7 @@ async function loadGrocery() { state.currentGroceryId = res.id; document.getElementById('grocery-empty').classList.add('hidden'); document.getElementById('btn-clear-grocery').classList.remove('hidden'); + document.getElementById('btn-copy-grocery').classList.remove('hidden'); if (res.notes) { document.getElementById('grocery-notes').textContent = res.notes; @@ -691,6 +693,48 @@ function renderGroceryList() { } } +function copyGroceryList() { + if (!state.currentGrocery) return; + + let items = state.currentGrocery.items; + if (typeof items === 'string') { + try { items = JSON.parse(items); } catch { items = []; } + } + if (!Array.isArray(items)) items = []; + + const total = items.filter(i => !i.checked).reduce((sum, item) => sum + (item.estimated_cost || 0), 0); + const grouped = {}; + items.forEach(item => { + const section = item.store_section || 'other'; + if (!grouped[section]) grouped[section] = []; + grouped[section].push(item); + }); + + let md = '## Grocery List\n'; + if (total > 0) md += `**Estimated total: $${total.toFixed(2)}**\n`; + + Object.keys(grouped).sort().forEach(section => { + const unchecked = grouped[section].filter(item => !item.checked); + if (!unchecked.length) return; + const title = section.charAt(0).toUpperCase() + section.slice(1); + md += `\n### ${title}\n`; + unchecked.forEach(item => { + const qty = [item.quantity, item.unit].filter(Boolean).join(' '); + md += `- [ ] ${qty ? qty + ' ' : ''}${item.name}\n`; + }); + }); + + const notes = state.currentGrocery.notes; + if (notes) md += `\n> ${notes}\n`; + + navigator.clipboard.writeText(md).then(() => { + const btn = document.getElementById('btn-copy-grocery'); + const orig = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(() => { btn.textContent = orig; }, 2000); + }).catch(() => showToast('Could not copy to clipboard', 'error')); +} + async function checkGroceryItem(checkbox) { const checked = checkbox.checked; const row = checkbox.closest('.grocery-item'); @@ -739,6 +783,7 @@ async function generateGrocery() { document.getElementById('grocery-empty').classList.add('hidden'); document.getElementById('btn-mark-purchased').classList.remove('hidden'); document.getElementById('btn-clear-grocery').classList.remove('hidden'); + document.getElementById('btn-copy-grocery').classList.remove('hidden'); if (res.shopping_notes) { document.getElementById('grocery-notes').textContent = res.shopping_notes; @@ -771,6 +816,7 @@ async function clearGrocery() { document.getElementById('grocery-notes').classList.add('hidden'); document.getElementById('btn-mark-purchased').classList.add('hidden'); document.getElementById('btn-clear-grocery').classList.add('hidden'); + document.getElementById('btn-copy-grocery').classList.add('hidden'); showToast('Grocery list cleared'); } catch (err) { showToast(err.message, 'error'); @@ -999,6 +1045,7 @@ async function init() { // Set up grocery form document.getElementById('btn-generate-grocery').addEventListener('click', generateGrocery); document.getElementById('btn-clear-grocery').addEventListener('click', clearGrocery); + document.getElementById('btn-copy-grocery').addEventListener('click', copyGroceryList); document.getElementById('btn-mark-purchased').addEventListener('click', markPurchased); // Set up chat diff --git a/static/index.html b/static/index.html index 7db0a86..f009e00 100644 --- a/static/index.html +++ b/static/index.html @@ -110,6 +110,7 @@ <textarea id="grocery-user-notes" class="ai-notes-input" placeholder="Any preferences or instructions for the AI (e.g. budget $50, no red meat, stock up on breakfast items)..." rows="2"></textarea> <button class="btn btn-primary" id="btn-generate-grocery">✨ Generate List</button> <button class="btn btn-danger hidden" id="btn-clear-grocery">Clear</button> + <button class="btn btn-secondary hidden" id="btn-copy-grocery">Copy</button> <button class="btn btn-success hidden" id="btn-mark-purchased">✓ Mark All Purchased</button> <div id="grocery-spinner" class="spinner hidden"></div> </div> diff --git a/static/recipe.html b/static/recipe.html index 2a5614f..34b0669 100644 --- a/static/recipe.html +++ b/static/recipe.html @@ -18,46 +18,195 @@ <script> const params = new URLSearchParams(location.search); const id = params.get('id'); + let currentRecipe = null; + let isEditMode = false; + + function parseIngredientsFromText(text) { + return text.split('\n') + .map(line => line.trim()) + .filter(line => line.length > 0) + .map(line => { + const parts = line.split(/\s+/); + if (parts.length >= 3) { + const quantity = parseFloat(parts[0]); + if (!isNaN(quantity)) { + const unit = parts[1]; + const name = parts.slice(2).join(' '); + return { quantity, unit, name }; + } + } + return { name: line, quantity: 1, unit: '' }; + }); + } + + function ingredientsToText(ingredients) { + if (!Array.isArray(ingredients)) return ''; + return ingredients.map(i => { + if (typeof i === 'string') return i; + return [i.quantity, i.unit, i.name].filter(Boolean).join(' '); + }).join('\n'); + } + + function renderViewMode(recipe) { + let ingredients = []; + try { ingredients = JSON.parse(recipe.ingredients); } catch {} + + const mealType = recipe.meal_type || 'dinner'; + const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); + const time = recipe.estimated_time_minutes; + const serves = recipe.servings; + const meta = [time ? `${time} min` : '', serves ? `serves ${serves}` : ''].filter(Boolean).join(' · '); + + const ingredientList = Array.isArray(ingredients) + ? ingredients.map(i => { + if (typeof i === 'string') return `<li>${i}</li>`; + return `<li>${[i.quantity, i.unit, i.name].filter(Boolean).join(' ')}</li>`; + }).join('') + : ''; + + const instructionSteps = (recipe.instructions || '') + .split(/\n+| (?=\d+\. )/) + .map(s => s.trim()) + .filter(Boolean) + .map(s => `<p>${s}</p>`) + .join(''); + + document.title = `${recipe.name} — Commis`; + document.getElementById('recipe-content').innerHTML = ` + <div class="recipe-card-header" style="margin-bottom:0.5rem;display:flex;justify-content:space-between;align-items:start;gap:1rem;"> + <div> + <h1 style="font-size:1.6rem;margin:0;">${recipe.name}</h1> + <span class="recipe-type-badge ${mealType}">${typeDisplay}</span> + </div> + <button class="btn btn-secondary" style="white-space:nowrap;margin-top:0.25rem;" onclick="enterEditMode()">Edit</button> + </div> + ${recipe.description ? `<p style="color:var(--text-muted);margin:0.5rem 0 1rem;">${recipe.description}</p>` : ''} + ${meta ? `<div class="recipe-card-meta" style="margin-bottom:1.5rem;">${meta}</div>` : ''} + ${ingredientList ? `<h2 style="font-size:1.1rem;margin-bottom:0.5rem;">Ingredients</h2><ul style="padding-left:1.25rem;line-height:1.8;">${ingredientList}</ul>` : ''} + ${instructionSteps ? `<h2 style="font-size:1.1rem;margin:1.5rem 0 0.5rem;">Instructions</h2><div style="line-height:1.7;">${instructionSteps}</div>` : ''} + `; + isEditMode = false; + } + + function renderEditMode(recipe) { + let ingredients = []; + try { ingredients = JSON.parse(recipe.ingredients); } catch {} + const ingredientText = ingredientsToText(ingredients); + + document.getElementById('recipe-content').innerHTML = ` + <div style="margin-bottom:1.5rem;"> + <button class="btn btn-secondary" style="margin-bottom:1rem;" onclick="exitEditMode()">← Back</button> + <h2 style="font-size:1.4rem;margin:0 0 1.5rem;">Edit Recipe</h2> + + <form id="edit-form" style="display:flex;flex-direction:column;gap:1.5rem;"> + <div> + <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Name</label> + <input type="text" id="edit-name" value="${recipe.name}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;"> + </div> + + <div> + <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Meal Type</label> + <select id="edit-meal-type" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;"> + <option value="breakfast" ${recipe.meal_type === 'breakfast' ? 'selected' : ''}>Breakfast</option> + <option value="lunch" ${recipe.meal_type === 'lunch' ? 'selected' : ''}>Lunch</option> + <option value="dinner" ${recipe.meal_type === 'dinner' ? 'selected' : ''}>Dinner</option> + </select> + </div> + + <div> + <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Description (optional)</label> + <textarea id="edit-description" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;min-height:80px;font-family:inherit;" placeholder="Brief description of the recipe">${recipe.description || ''}</textarea> + </div> + + <div> + <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Time (minutes, optional)</label> + <input type="number" id="edit-time" value="${recipe.estimated_time_minutes || ''}" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;"> + </div> + + <div> + <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Servings</label> + <input type="number" id="edit-servings" value="${recipe.servings}" min="1" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;"> + </div> + + <div> + <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Ingredients</label> + <textarea id="edit-ingredients" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;min-height:150px;font-family:monospace;" placeholder="One ingredient per line (e.g., 1 cup flour)">${ingredientText}</textarea> + <p style="color:var(--text-muted);font-size:0.9rem;margin-top:0.25rem;">Format: quantity unit name (e.g., 1 cup flour)</p> + </div> + + <div> + <label style="display:block;margin-bottom:0.5rem;font-weight:500;">Instructions</label> + <textarea id="edit-instructions" style="width:100%;padding:0.5rem;border:1px solid var(--border-color);border-radius:6px;font-size:1rem;box-sizing:border-box;min-height:150px;font-family:inherit;" placeholder="Step-by-step instructions">${recipe.instructions || ''}</textarea> + </div> + + <div style="display:flex;gap:1rem;"> + <button type="button" class="btn btn-primary" onclick="saveRecipe()" style="flex:1;">Save</button> + <button type="button" class="btn btn-secondary" onclick="exitEditMode()" style="flex:1;">Cancel</button> + </div> + </form> + </div> + `; + isEditMode = true; + } + + function enterEditMode() { + if (currentRecipe) renderEditMode(currentRecipe); + } + + function exitEditMode() { + if (currentRecipe) renderViewMode(currentRecipe); + } + + async function saveRecipe() { + const name = document.getElementById('edit-name').value.trim(); + const meal_type = document.getElementById('edit-meal-type').value; + const description = document.getElementById('edit-description').value.trim(); + const time_minutes = document.getElementById('edit-time').value; + const servings = parseInt(document.getElementById('edit-servings').value, 10) || 2; + const ingredientText = document.getElementById('edit-ingredients').value.trim(); + const instructions = document.getElementById('edit-instructions').value.trim(); + + if (!name) { + alert('Recipe name is required.'); + return; + } + + const ingredients = parseIngredientsFromText(ingredientText); + const payload = { + name, + meal_type, + description: description || null, + estimated_time_minutes: time_minutes ? parseInt(time_minutes, 10) : null, + servings, + ingredients: JSON.stringify(ingredients), + instructions: instructions || null, + }; + + try { + const response = await fetch(`/api/recipes/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + const updated = await response.json(); + currentRecipe = updated; + renderViewMode(currentRecipe); + } catch (err) { + alert(`Failed to save recipe: ${err.message}`); + } + } + if (!id) { document.getElementById('recipe-content').innerHTML = '<p>No recipe ID provided.</p>'; } else { fetch(`/api/recipes/${id}`) .then(r => r.ok ? r.json() : Promise.reject(r.statusText)) .then(recipe => { - let ingredients = []; - try { ingredients = JSON.parse(recipe.ingredients); } catch {} - - const mealType = recipe.meal_type || 'dinner'; - const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1); - const time = recipe.estimated_time_minutes; - const serves = recipe.servings; - const meta = [time ? `${time} min` : '', serves ? `serves ${serves}` : ''].filter(Boolean).join(' · '); - - const ingredientList = Array.isArray(ingredients) - ? ingredients.map(i => { - if (typeof i === 'string') return `<li>${i}</li>`; - return `<li>${[i.quantity, i.unit, i.name].filter(Boolean).join(' ')}</li>`; - }).join('') - : ''; - - const instructionSteps = (recipe.instructions || '') - .split(/\n+| (?=\d+\. )/) - .map(s => s.trim()) - .filter(Boolean) - .map(s => `<p>${s}</p>`) - .join(''); - - document.title = `${recipe.name} — Commis`; - document.getElementById('recipe-content').innerHTML = ` - <div class="recipe-card-header" style="margin-bottom:0.5rem;"> - <h1 style="font-size:1.6rem;margin:0;">${recipe.name}</h1> - <span class="recipe-type-badge ${mealType}">${typeDisplay}</span> - </div> - ${recipe.description ? `<p style="color:var(--text-muted);margin:0.5rem 0 1rem;">${recipe.description}</p>` : ''} - ${meta ? `<div class="recipe-card-meta" style="margin-bottom:1.5rem;">${meta}</div>` : ''} - ${ingredientList ? `<h2 style="font-size:1.1rem;margin-bottom:0.5rem;">Ingredients</h2><ul style="padding-left:1.25rem;line-height:1.8;">${ingredientList}</ul>` : ''} - ${instructionSteps ? `<h2 style="font-size:1.1rem;margin:1.5rem 0 0.5rem;">Instructions</h2><div style="line-height:1.7;">${instructionSteps}</div>` : ''} - `; + currentRecipe = recipe; + renderViewMode(recipe); }) .catch(err => { document.getElementById('recipe-content').innerHTML = `<p>Failed to load recipe: ${err}</p>`; |
