diff options
| author | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-08 03:24:36 -0700 |
|---|---|---|
| committer | Tyler Hoang <tyler@tylerhoang.xyz> | 2026-05-08 03:24:36 -0700 |
| commit | 3de7c5eed5ba262abf0d746211e33800db6d66df (patch) | |
| tree | 6fddb5381fb178423eac34894add5b611babe300 /routers/menus.py | |
| parent | f361e7599d9a11ad3397b7b6bffee151ab9bdde9 (diff) | |
Add recipe suggestions, chat tab, and major workflow improvements
- Replace 7-day grid menu with browsable recipe suggestion cards (swap, remove, make this)
- Add Chat tab: conversational AI with full pantry/menu/grocery context
- Grocery list works without a menu (pantry-only mode)
- Individual grocery checkboxes auto-add items to pantry
- Swap modal with optional preference input
- User notes textarea on menu and grocery generation
- Clear button for menu and grocery list
- LLM notes/summary displayed after generation, persisted to DB
- Favicon linked in HTML
- Category dropdown styled for dark theme
- System prompt configurable via SYSTEM_PROMPT in .env
- Fix startup error (ollama_timeout default), DB migration for menu_plans.notes
- Simplify: batch N+1 queries, extract _current_monday(), merge chat sync fns,
asyncio.get_running_loop(), fix currentGroceryId bug, cap chat history
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Diffstat (limited to 'routers/menus.py')
| -rw-r--r-- | routers/menus.py | 191 |
1 files changed, 149 insertions, 42 deletions
diff --git a/routers/menus.py b/routers/menus.py index 49c4654..9ec1d1d 100644 --- a/routers/menus.py +++ b/routers/menus.py @@ -1,4 +1,4 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Body from sqlalchemy.orm import Session from datetime import datetime, timedelta, date import json @@ -12,11 +12,15 @@ from config import settings router = APIRouter(prefix="/api/menus", tags=["menus"]) +def _current_monday(): + today = datetime.utcnow().date() + return today - timedelta(days=today.weekday()) + + @router.get("/current") async def get_current_menu(db: Session = Depends(get_db)): """Get menu plan for current week.""" - today = datetime.utcnow().date() - monday = today - timedelta(days=today.weekday()) + monday = _current_monday() menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() if not menu: @@ -37,67 +41,64 @@ async def generate_menu(request: dict = None, db: Session = Depends(get_db)): try: if isinstance(week_start, str): week_start = datetime.fromisoformat(week_start).date() - else: - week_start = week_start except Exception: raise HTTPException(status_code=400, detail="Invalid week_start date") else: - today = datetime.utcnow().date() - week_start = today - timedelta(days=today.weekday()) + week_start = _current_monday() # Build pantry context and generate menu try: pantry_context = pantry_service.build_pantry_context(db) - ai_result = await ai_service.generate_weekly_menu(pantry_context) + user_notes = request.get("user_notes") if request and isinstance(request, dict) else None + ai_result = await ai_service.generate_weekly_menu(pantry_context, user_notes) except ValueError as e: raise HTTPException(status_code=503, detail=str(e)) - except (ConnectionError, Exception) as e: + except Exception as e: raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") - # Save recipes and build plan dict - week_plan = ai_result.get("week_plan", {}) - plan_dict = {} - - for day, meals in week_plan.items(): - plan_dict[day] = {} - for meal_type, meal_data in meals.items(): - if isinstance(meal_data, dict) and "name" in meal_data: - meal_name = meal_data["name"] + # Save recipes and build plan list (flat array of IDs) + recipes_ai = ai_result.get("recipes", []) + plan_ids = [] - # Check if recipe exists by name - existing_recipe = db.query(Recipe).filter(Recipe.name == meal_name).first() - if existing_recipe: - recipe_id = existing_recipe.id - else: - # Create new recipe - new_recipe = Recipe( - name=meal_name, - meal_type=meal_type, - ingredients=json.dumps(meal_data.get("ingredients", [])), - instructions=meal_data.get("instructions", ""), - estimated_time_minutes=meal_data.get("time_minutes", 30), - servings=meal_data.get("serves", 2), - source="ai", - ) - db.add(new_recipe) - db.flush() - recipe_id = new_recipe.id + valid_recipes = [r for r in recipes_ai if isinstance(r, dict) and "name" in r] + all_names = [r["name"] for r in valid_recipes] + existing = db.query(Recipe).filter(Recipe.name.in_(all_names)).all() + existing_by_name = {r.name: r for r in existing} - plan_dict[day][meal_type] = recipe_id + for recipe_data in valid_recipes: + meal_name = recipe_data["name"] + if meal_name in existing_by_name: + recipe_id = existing_by_name[meal_name].id + else: + new_recipe = Recipe( + name=meal_name, + 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", 30), + servings=recipe_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + recipe_id = new_recipe.id + plan_ids.append(recipe_id) - # Upsert MenuPlan + ai_notes = ai_result.get("notes", "") existing_plan = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first() if existing_plan: - existing_plan.plan = json.dumps(plan_dict) + existing_plan.plan = json.dumps(plan_ids) existing_plan.generated_at = datetime.utcnow() existing_plan.model_used = settings.model_name + existing_plan.notes = ai_notes menu = existing_plan else: menu = MenuPlan( week_start=week_start, - plan=json.dumps(plan_dict), + plan=json.dumps(plan_ids), generated_at=datetime.utcnow(), model_used=settings.model_name, + notes=ai_notes, ) db.add(menu) @@ -106,8 +107,8 @@ async def generate_menu(request: dict = None, db: Session = Depends(get_db)): return { "menu_plan": MenuPlanRead.from_orm(menu), - "week_plan": week_plan, - "notes": ai_result.get("notes", ""), + "recipes": recipes_ai, + "notes": menu.notes or "", } @@ -127,6 +128,112 @@ async def get_menu(week_start: date, db: Session = Depends(get_db)): return MenuPlanRead.from_orm(menu) +@router.delete("/current") +async def delete_current_menu(db: Session = Depends(get_db)): + """Delete the menu plan for the current week.""" + monday = _current_monday() + menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if not menu: + raise HTTPException(status_code=404, detail="No menu plan for current week") + db.delete(menu) + db.commit() + return {"status": "deleted"} + + +@router.delete("/current/recipes/{recipe_id}") +async def remove_recipe_from_menu(recipe_id: int, db: Session = Depends(get_db)): + """Remove a single recipe from the current week's plan.""" + monday = _current_monday() + menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if not menu: + raise HTTPException(status_code=404, detail="No menu plan for current week") + + try: + plan_ids = json.loads(menu.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") + + if recipe_id not in plan_ids: + raise HTTPException(status_code=404, detail="Recipe not in current menu plan") + + plan_ids.remove(recipe_id) + menu.plan = json.dumps(plan_ids) + db.commit() + return {"status": "removed", "plan": plan_ids} + + +@router.post("/current/recipes/{recipe_id}/swap") +async def swap_recipe_in_menu(recipe_id: int, request: dict = Body(default={}), db: Session = Depends(get_db)): + """Swap a single recipe in the current week's plan with a new AI suggestion.""" + monday = _current_monday() + menu = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first() + if not menu: + raise HTTPException(status_code=404, detail="No menu plan for current week") + + try: + plan_ids = json.loads(menu.plan) + except json.JSONDecodeError: + raise HTTPException(status_code=500, detail="Invalid menu plan data") + + # Find the recipe being swapped + old_recipe = db.query(Recipe).filter(Recipe.id == recipe_id).first() + if not old_recipe: + raise HTTPException(status_code=404, detail="Recipe not found") + + meal_type = old_recipe.meal_type + + # Get names of all other recipes in the plan to avoid repeats + other_ids = [rid for rid in plan_ids if rid != recipe_id] + other_recipes = db.query(Recipe).filter(Recipe.id.in_(other_ids)).all() if other_ids else [] + existing_names = [r.name for r in other_recipes] + + # Generate replacement + user_notes = request.get("user_notes") if request else None + try: + pantry_context = pantry_service.build_pantry_context(db) + ai_result = await ai_service.generate_replacement_recipe(meal_type, existing_names, pantry_context, user_notes) + except ValueError as e: + raise HTTPException(status_code=503, detail=str(e)) + except Exception as e: + raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}") + + recipe_data = ai_result.get("recipe", {}) + if not recipe_data or "name" not in recipe_data: + raise HTTPException(status_code=503, detail="AI returned invalid recipe data") + + # Save new recipe + new_recipe = Recipe( + name=recipe_data["name"], + meal_type=recipe_data.get("meal_type", meal_type), + ingredients=json.dumps(recipe_data.get("ingredients", [])), + instructions=recipe_data.get("instructions", ""), + estimated_time_minutes=recipe_data.get("time_minutes", 30), + servings=recipe_data.get("serves", 2), + source="ai", + ) + db.add(new_recipe) + db.flush() + + # Swap the ID in the plan + idx = plan_ids.index(recipe_id) + plan_ids[idx] = new_recipe.id + menu.plan = json.dumps(plan_ids) + db.commit() + db.refresh(new_recipe) + + return { + "recipe": { + "id": new_recipe.id, + "name": new_recipe.name, + "meal_type": new_recipe.meal_type, + "ingredients": recipe_data.get("ingredients", []), + "instructions": new_recipe.instructions, + "estimated_time_minutes": new_recipe.estimated_time_minutes, + "servings": new_recipe.servings, + } + } + + @router.delete("/{id}") async def delete_menu(id: int, db: Session = Depends(get_db)): """Delete a menu plan.""" |
