From 360eadf78fb001e947f3850603152adc413bb3a8 Mon Sep 17 00:00:00 2001 From: Tyler Hoang Date: Sat, 9 May 2026 02:31:10 -0700 Subject: Recipe detail page, menu revamp, and UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add recipe detail page (recipe.html) with full ingredients and instructions - Simplify menu tab: cards show name + description only, click through for full recipe - Add description field to Recipe model with DB migration - Add AI-generated descriptions to menu, swap, and import prompts - Add single dish by description (POST /api/menus/current/recipes) - Add grocery item delete without pantry add (DELETE /api/grocery/{id}/items) - Persist grocery checked state server-side (PATCH /api/grocery/{id}/check-item) - Hash-based tab routing — refresh stays on current tab - Logo branding in header and favicon - Dark theme fixes: URL/text inputs, amber accent, muted danger/warning colors - Markdown rendering in chat (bold, italic, code blocks, lists, headers) - Fix instruction step splitting for inline-numbered steps (1. 2. 3.) - Import recipe from URL with JSON-LD structured data + AI fallback Co-Authored-By: Claude Sonnet 4.6 --- routers/grocery.py | 22 ++++++++ routers/menus.py | 68 ++++++++++++++++++++++++- routers/recipes.py | 144 ++++++++++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 231 insertions(+), 3 deletions(-) (limited to 'routers') diff --git a/routers/grocery.py b/routers/grocery.py index cbc66bb..a74776d 100644 --- a/routers/grocery.py +++ b/routers/grocery.py @@ -211,6 +211,28 @@ async def mark_purchased(id: int, db: Session = Depends(get_db)): return GroceryListRead.from_orm(grocery_list) +@router.delete("/{id}/items") +async def delete_grocery_item(id: int, request: dict = Body(...), db: Session = Depends(get_db)): + """Remove a single item from a grocery list by name.""" + grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first() + if not grocery_list: + raise HTTPException(status_code=404, detail="Grocery list not found") + + item_name = request.get("name") + if not item_name: + raise HTTPException(status_code=400, detail="Item name is required") + + try: + items = json.loads(grocery_list.items) + except json.JSONDecodeError: + items = [] + + items = [i for i in items if not (isinstance(i, dict) and i.get("name") == item_name)] + grocery_list.items = json.dumps(items) + db.commit() + return {"status": "ok"} + + @ai_router.post("/suggest-recipe") async def suggest_recipe_endpoint(db: Session = Depends(get_db)): """Suggest a recipe based on current pantry.""" diff --git a/routers/menus.py b/routers/menus.py index 9ec1d1d..db56d73 100644 --- a/routers/menus.py +++ b/routers/menus.py @@ -4,7 +4,7 @@ from datetime import datetime, timedelta, date import json from database import get_db -from schemas import MenuPlanRead +from schemas import MenuPlanRead, RecipeRead from models import MenuPlan, Recipe from services import pantry_service, ai_service from config import settings @@ -78,6 +78,7 @@ async def generate_menu(request: dict = None, db: Session = Depends(get_db)): estimated_time_minutes=recipe_data.get("time_minutes", 30), servings=recipe_data.get("serves", 2), source="ai", + description=recipe_data.get("description", ""), ) db.add(new_recipe) db.flush() @@ -234,6 +235,71 @@ async def swap_recipe_in_menu(recipe_id: int, request: dict = Body(default={}), } +@router.post("/current/recipes") +async def add_recipe_to_menu(request: dict = Body(default={}), db: Session = Depends(get_db)): + """Generate and add a single recipe by description.""" + description = (request.get("description") or "").strip() + if not description: + raise HTTPException(status_code=400, detail="Description is required") + + monday = _current_monday() + menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + + # Get existing recipe names to avoid duplicates + existing_names = [] + if menu_plan: + try: + plan_ids = json.loads(menu_plan.plan) + existing = db.query(Recipe).filter(Recipe.id.in_(plan_ids)).all() if plan_ids else [] + existing_names = [r.name for r in existing] + except Exception: + pass + + pantry_context = pantry_service.build_pantry_context(db) + + try: + result = await ai_service.generate_single_recipe(description, existing_names, pantry_context) + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=503, detail=f"Ollama error: {str(e)}") + + recipe_data = result.get("recipe", {}) + if not recipe_data.get("name"): + raise HTTPException(status_code=503, detail="AI returned invalid recipe") + + new_recipe = Recipe( + name=recipe_data["name"], + description=recipe_data.get("description", ""), + meal_type=recipe_data.get("meal_type", "dinner"), + ingredients=json.dumps(recipe_data.get("ingredients", [])), + instructions=recipe_data.get("instructions", ""), + estimated_time_minutes=recipe_data.get("time_minutes"), + servings=recipe_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + + if menu_plan: + try: + plan_ids = json.loads(menu_plan.plan) + except Exception: + plan_ids = [] + plan_ids.append(new_recipe.id) + menu_plan.plan = json.dumps(plan_ids) + else: + menu_plan = MenuPlan( + week_start=monday, + plan=json.dumps([new_recipe.id]), + ) + db.add(menu_plan) + + db.commit() + db.refresh(new_recipe) + return {"recipe": RecipeRead.from_orm(new_recipe)} + + @router.delete("/{id}") async def delete_menu(id: int, db: Session = Depends(get_db)): """Delete a menu plan.""" diff --git a/routers/recipes.py b/routers/recipes.py index f215062..000f945 100644 --- a/routers/recipes.py +++ b/routers/recipes.py @@ -1,14 +1,24 @@ -from fastapi import APIRouter, Depends, HTTPException, Query, status +from fastapi import APIRouter, Depends, HTTPException, Query, status, Body from sqlalchemy.orm import Session from typing import List +import httpx +import json +from datetime import datetime, timedelta +from bs4 import BeautifulSoup from database import get_db -from models import Recipe +from models import Recipe, MenuPlan from schemas import RecipeCreate, RecipeRead, RecipeUpdate +from services import ai_service router = APIRouter(prefix="/api/recipes", tags=["recipes"]) +def _current_monday(): + today = datetime.utcnow().date() + return today - timedelta(days=today.weekday()) + + @router.get("", response_model=List[RecipeRead]) def list_recipes(meal_type: str = Query(None), db: Session = Depends(get_db)): """List all recipes. Optional ?meal_type=dinner.""" @@ -48,3 +58,133 @@ def delete_recipe(id: int, db: Session = Depends(get_db)): db.delete(recipe) db.commit() + + +@router.post("/import") +async def import_recipe(request: dict = Body(default={}), db: Session = Depends(get_db)): + """Import a recipe from a URL.""" + url = (request.get("url") or "").strip() + if not url: + raise HTTPException(status_code=400, detail="URL is required") + + # Fetch the page + try: + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(url, headers={"User-Agent": "Mozilla/5.0"}) + resp.raise_for_status() + except Exception as e: + raise HTTPException(status_code=400, detail=f"Could not fetch URL: {e}") + + soup = BeautifulSoup(resp.text, "html.parser") + recipe_data = None + + # Try JSON-LD structured data first + for script in soup.find_all("script", type="application/ld+json"): + try: + ld = json.loads(script.string or "") + # Handle @graph wrapper + if isinstance(ld, dict) and ld.get("@graph"): + ld = next((x for x in ld["@graph"] if x.get("@type") == "Recipe"), None) + if isinstance(ld, list): + ld = next((x for x in ld if x.get("@type") == "Recipe"), None) + if isinstance(ld, dict) and ld.get("@type") == "Recipe": + recipe_data = ld + break + except Exception: + continue + + if recipe_data: + # Map JSON-LD Recipe schema to our format + def parse_duration(iso): + if not iso: + return None + import re + m = re.search(r'(\d+)M', iso) + h = re.search(r'(\d+)H', iso) + return (int(h.group(1)) * 60 if h else 0) + (int(m.group(1)) if m else 0) + + raw_ingredients = recipe_data.get("recipeIngredient") or [] + ingredients = [{"name": ing, "quantity": 1.0, "unit": ""} for ing in raw_ingredients] + + raw_instructions = recipe_data.get("recipeInstructions") or [] + if isinstance(raw_instructions, list): + steps = [] + for step in raw_instructions: + if isinstance(step, dict): + steps.append(step.get("text", "")) + else: + steps.append(str(step)) + instructions = " ".join(steps) + else: + instructions = str(raw_instructions) + + name = recipe_data.get("name") or "Imported Recipe" + time_minutes = parse_duration(recipe_data.get("totalTime") or recipe_data.get("cookTime")) + serves_raw = recipe_data.get("recipeYield") + if isinstance(serves_raw, list): + serves_raw = serves_raw[0] + try: + serves = int(str(serves_raw).split()[0]) if serves_raw else 2 + except Exception: + serves = 2 + + recipe_category = str(recipe_data.get("recipeCategory") or "").lower() + if "breakfast" in recipe_category or "brunch" in recipe_category: + meal_type = "breakfast" + elif "lunch" in recipe_category or "salad" in recipe_category or "sandwich" in recipe_category: + meal_type = "lunch" + else: + meal_type = "dinner" + + parsed = { + "name": name, + "meal_type": meal_type, + "ingredients": ingredients, + "instructions": instructions, + "time_minutes": time_minutes, + "serves": serves, + } + else: + # Fall back to AI parsing of visible text + text = soup.get_text(separator=" ", strip=True) + try: + result = await ai_service.parse_recipe_from_text(text) + except Exception as e: + raise HTTPException(status_code=503, detail=f"AI parsing failed: {e}") + parsed = result.get("recipe", {}) + + # Save recipe to DB + db_recipe = Recipe( + name=parsed.get("name", "Imported Recipe"), + meal_type=parsed.get("meal_type", "dinner"), + ingredients=json.dumps(parsed.get("ingredients", [])), + instructions=parsed.get("instructions", ""), + estimated_time_minutes=parsed.get("time_minutes"), + servings=parsed.get("serves", 2), + source="import", + description=parsed.get("description", ""), + ) + db.add(db_recipe) + db.commit() + db.refresh(db_recipe) + + # Add to current week's menu plan + monday = _current_monday() + menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if menu_plan: + try: + plan_ids = json.loads(menu_plan.plan) + except Exception: + plan_ids = [] + plan_ids.append(db_recipe.id) + menu_plan.plan = json.dumps(plan_ids) + db.commit() + else: + menu_plan = MenuPlan( + week_start=monday, + plan=json.dumps([db_recipe.id]), + ) + db.add(menu_plan) + db.commit() + + return {"recipe": RecipeRead.from_orm(db_recipe)} -- cgit v1.3-2-g0d8e