summaryrefslogtreecommitdiff
path: root/routers
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-09 02:31:10 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-09 02:31:10 -0700
commit360eadf78fb001e947f3850603152adc413bb3a8 (patch)
tree2d67065890ce195bc2c0f2f6e430e86c241b2424 /routers
parentaba03fd72df5729a86d21c6866761b43a8abad68 (diff)
Recipe detail page, menu revamp, and UX improvements
- 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 <noreply@anthropic.com>
Diffstat (limited to 'routers')
-rw-r--r--routers/grocery.py22
-rw-r--r--routers/menus.py68
-rw-r--r--routers/recipes.py144
3 files changed, 231 insertions, 3 deletions
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)}