summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 01:58:48 -0700
committerTyler Hoang <tyler@tylerhoang.xyz>2026-05-08 01:58:48 -0700
commit2e0a94e88c847a5ed8dc6ad5fa49715cd631bdfe (patch)
tree0c27fc5a8d8cbba60e571bb6690a13c0c0060ff4
Initial commit — Commis personal chef app
AI-powered local chef tool: pantry tracking, meal logging, rotating weekly menu generation, and grocery list optimization via Ollama (llama3). FastAPI backend, SQLite, vanilla JS frontend. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
-rw-r--r--.gitignore7
-rw-r--r--config.py16
-rw-r--r--database.py20
-rw-r--r--main.py51
-rw-r--r--models.py75
-rw-r--r--requirements.txt6
-rw-r--r--routers/__init__.py0
-rw-r--r--routers/grocery.py186
-rw-r--r--routers/meals.py97
-rw-r--r--routers/menus.py138
-rw-r--r--routers/pantry.py101
-rw-r--r--routers/recipes.py50
-rw-r--r--schemas.py203
-rw-r--r--services/__init__.py0
-rw-r--r--services/ai_service.py158
-rw-r--r--services/pantry_service.py51
-rw-r--r--static/app.js757
-rw-r--r--static/index.html127
-rw-r--r--static/style.css823
19 files changed, 2866 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..dc45fb3
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+venv/
+__pycache__/
+*.pyc
+*.pyo
+*.db
+.env
+.claude/
diff --git a/config.py b/config.py
new file mode 100644
index 0000000..8472e66
--- /dev/null
+++ b/config.py
@@ -0,0 +1,16 @@
+from pydantic_settings import BaseSettings
+
+
+class Settings(BaseSettings):
+ ollama_host: str
+ model_name: str
+ database_url: str
+ ollama_timeout: int
+
+ class Config:
+ env_file = ".env"
+ env_file_encoding = "utf-8"
+ protected_namespaces = ("settings_",)
+
+
+settings = Settings()
diff --git a/database.py b/database.py
new file mode 100644
index 0000000..aab1d4f
--- /dev/null
+++ b/database.py
@@ -0,0 +1,20 @@
+from sqlalchemy import create_engine
+from sqlalchemy.orm import sessionmaker, declarative_base
+from config import settings
+
+engine = create_engine(
+ settings.database_url,
+ connect_args={"check_same_thread": False}
+)
+
+SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
+
+Base = declarative_base()
+
+
+def get_db():
+ db = SessionLocal()
+ try:
+ yield db
+ finally:
+ db.close()
diff --git a/main.py b/main.py
new file mode 100644
index 0000000..fdbdea9
--- /dev/null
+++ b/main.py
@@ -0,0 +1,51 @@
+from contextlib import asynccontextmanager
+import asyncio
+from fastapi import FastAPI
+from fastapi.staticfiles import StaticFiles
+from database import engine, Base
+from config import settings
+from routers import pantry, meals, recipes, menus, grocery
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+ # Startup
+ Base.metadata.create_all(bind=engine)
+ yield
+ # Shutdown
+ pass
+
+
+app = FastAPI(title="Commis", lifespan=lifespan)
+
+# Include routers first so API routes take priority over the static catch-all
+app.include_router(pantry.router)
+app.include_router(meals.router)
+app.include_router(recipes.router)
+app.include_router(menus.router)
+app.include_router(grocery.router)
+app.include_router(grocery.ai_router)
+
+
+@app.get("/api/health")
+async def health():
+ try:
+ import ollama
+ client = ollama.Client(host=settings.ollama_host)
+ loop = asyncio.get_event_loop()
+ models_resp = await loop.run_in_executor(None, client.list)
+ model_names = [m["name"] for m in models_resp.get("models", [])]
+ ollama_status = "connected"
+ except Exception:
+ model_names = []
+ ollama_status = "disconnected"
+ return {
+ "status": "ok",
+ "ollama": ollama_status,
+ "model": settings.model_name,
+ "available_models": model_names,
+ }
+
+
+# Static files mount last — acts as a catch-all, must come after all API routes
+app.mount("/", StaticFiles(directory="static", html=True), name="static")
diff --git a/models.py b/models.py
new file mode 100644
index 0000000..7594409
--- /dev/null
+++ b/models.py
@@ -0,0 +1,75 @@
+from datetime import datetime, date
+from sqlalchemy import Column, Integer, String, Float, DateTime, Date, Text, Boolean, ForeignKey
+from sqlalchemy.orm import relationship
+from database import Base
+
+
+class Ingredient(Base):
+ __tablename__ = "ingredients"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String, nullable=False)
+ quantity = Column(Float, nullable=False)
+ unit = Column(String, nullable=False)
+ category = Column(String, nullable=True)
+ expiry_date = Column(Date, nullable=True)
+ added_at = Column(DateTime, default=datetime.utcnow)
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
+
+
+class MealLog(Base):
+ __tablename__ = "meal_log"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ meal_name = Column(String, nullable=False)
+ meal_type = Column(String, nullable=False)
+ eaten_at = Column(DateTime, nullable=False, default=datetime.utcnow)
+ notes = Column(String, nullable=True)
+ servings = Column(Integer, default=1)
+ ingredients = relationship("MealIngredient", cascade="all, delete-orphan")
+
+
+class MealIngredient(Base):
+ __tablename__ = "meal_ingredients"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ meal_log_id = Column(Integer, ForeignKey("meal_log.id", ondelete="CASCADE"), nullable=False)
+ ingredient_name = Column(String, nullable=False)
+ quantity_used = Column(Float, nullable=True)
+ unit = Column(String, nullable=True)
+
+
+class Recipe(Base):
+ __tablename__ = "recipes"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ name = Column(String, nullable=False)
+ meal_type = Column(String, nullable=False)
+ ingredients = Column(Text, nullable=False)
+ instructions = Column(Text, nullable=True)
+ estimated_time_minutes = Column(Integer, nullable=True)
+ servings = Column(Integer, default=2)
+ source = Column(String, default="ai")
+ created_at = Column(DateTime, default=datetime.utcnow)
+
+
+class MenuPlan(Base):
+ __tablename__ = "menu_plans"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ week_start = Column(Date, nullable=False, unique=True)
+ plan = Column(Text, nullable=False)
+ generated_at = Column(DateTime, default=datetime.utcnow)
+ model_used = Column(String, nullable=True)
+
+
+class GroceryList(Base):
+ __tablename__ = "grocery_lists"
+
+ id = Column(Integer, primary_key=True, autoincrement=True)
+ generated_for = Column(Date, nullable=False)
+ items = Column(Text, nullable=False)
+ total_estimate = Column(Float, nullable=True)
+ generated_at = Column(DateTime, default=datetime.utcnow)
+ is_purchased = Column(Boolean, default=False)
+ notes = Column(Text, nullable=True)
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..c32f824
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,6 @@
+fastapi==0.115.0
+uvicorn[standard]==0.30.6
+sqlalchemy==2.0.36
+pydantic-settings==2.5.2
+python-dotenv==1.0.1
+ollama==0.3.3
diff --git a/routers/__init__.py b/routers/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/routers/__init__.py
diff --git a/routers/grocery.py b/routers/grocery.py
new file mode 100644
index 0000000..cde2068
--- /dev/null
+++ b/routers/grocery.py
@@ -0,0 +1,186 @@
+from fastapi import APIRouter, Depends, HTTPException, Body
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+import json
+
+from database import get_db
+from schemas import GroceryListRead
+from models import GroceryList, MenuPlan, Recipe, Ingredient
+from services import pantry_service, ai_service
+
+router = APIRouter(prefix="/api/grocery", tags=["grocery"])
+ai_router = APIRouter(prefix="/api/ai", tags=["ai"])
+
+
+@router.get("")
+async def list_grocery_lists(db: Session = Depends(get_db)):
+ """List all grocery lists."""
+ lists = db.query(GroceryList).order_by(GroceryList.generated_at.desc()).all()
+ return [GroceryListRead.from_orm(l) for l in lists]
+
+
+@router.get("/current")
+async def get_current_grocery_list(db: Session = Depends(get_db)):
+ """Get grocery list for current week."""
+ today = datetime.utcnow().date()
+ monday = today - timedelta(days=today.weekday())
+
+ grocery_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first()
+ if not grocery_list:
+ raise HTTPException(status_code=404, detail="No grocery list for current week")
+ return GroceryListRead.from_orm(grocery_list)
+
+
+@router.get("/{id}")
+async def get_grocery_list(id: int, db: Session = Depends(get_db)):
+ """Get a grocery list by ID."""
+ grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first()
+ if not grocery_list:
+ raise HTTPException(status_code=404, detail="Grocery list not found")
+ return GroceryListRead.from_orm(grocery_list)
+
+
+@router.post("/generate")
+async def generate_grocery_list(db: Session = Depends(get_db)):
+ """Generate a grocery list for the current week."""
+ # Get current week's Monday
+ today = datetime.utcnow().date()
+ monday = today - timedelta(days=today.weekday())
+
+ # Fetch current MenuPlan
+ menu_plan = db.query(MenuPlan).filter(MenuPlan.week_start == monday).first()
+ if not menu_plan:
+ raise HTTPException(status_code=404, detail="No menu plan for current week. Generate a menu first.")
+
+ # Parse the plan JSON
+ try:
+ plan_dict = json.loads(menu_plan.plan)
+ except json.JSONDecodeError:
+ raise HTTPException(status_code=500, detail="Invalid menu plan data")
+
+ # Collect all recipe IDs and fetch recipes
+ recipe_ids = set()
+ for day_meals in plan_dict.values():
+ if isinstance(day_meals, dict):
+ for recipe_id in day_meals.values():
+ if isinstance(recipe_id, int):
+ recipe_ids.add(recipe_id)
+
+ recipes = db.query(Recipe).filter(Recipe.id.in_(recipe_ids)).all() if recipe_ids else []
+ recipe_map = {r.id: r for r in recipes}
+
+ # Build menu_plan_for_ai
+ menu_plan_for_ai = {}
+ for day, day_meals in plan_dict.items():
+ menu_plan_for_ai[day] = {}
+ for meal_type, recipe_id in day_meals.items():
+ recipe = recipe_map.get(recipe_id)
+ if recipe:
+ try:
+ ingredients = json.loads(recipe.ingredients)
+ except (json.JSONDecodeError, TypeError):
+ ingredients = []
+ menu_plan_for_ai[day][meal_type] = {
+ "name": recipe.name,
+ "ingredients": ingredients,
+ }
+
+ # Build pantry context and generate grocery list
+ try:
+ pantry_context = pantry_service.build_pantry_context(db)
+ ai_result = await ai_service.generate_grocery_list(menu_plan_for_ai, pantry_context)
+ except ValueError as e:
+ raise HTTPException(status_code=503, detail=str(e))
+ except (ConnectionError, Exception) as e:
+ raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}")
+
+ # Upsert GroceryList
+ existing_list = db.query(GroceryList).filter(GroceryList.generated_for == monday).first()
+ if existing_list:
+ existing_list.items = json.dumps(ai_result.get("items", []))
+ existing_list.total_estimate = ai_result.get("total_estimate", 0.0)
+ existing_list.notes = ai_result.get("shopping_notes", "")
+ existing_list.generated_at = datetime.utcnow()
+ grocery_list = existing_list
+ else:
+ grocery_list = GroceryList(
+ generated_for=monday,
+ items=json.dumps(ai_result.get("items", [])),
+ total_estimate=ai_result.get("total_estimate", 0.0),
+ notes=ai_result.get("shopping_notes", ""),
+ generated_at=datetime.utcnow(),
+ )
+ db.add(grocery_list)
+
+ db.commit()
+ db.refresh(grocery_list)
+
+ return {
+ "grocery_list": GroceryListRead.from_orm(grocery_list),
+ "items": ai_result.get("items", []),
+ "shopping_notes": ai_result.get("shopping_notes", ""),
+ }
+
+
+@router.put("/{id}/purchased")
+async def mark_purchased(id: int, db: Session = Depends(get_db)):
+ """Mark a grocery list as purchased and update pantry."""
+ grocery_list = db.query(GroceryList).filter(GroceryList.id == id).first()
+ if not grocery_list:
+ raise HTTPException(status_code=404, detail="Grocery list not found")
+
+ # Mark as purchased
+ grocery_list.is_purchased = True
+
+ # Parse items
+ try:
+ items = json.loads(grocery_list.items)
+ except json.JSONDecodeError:
+ items = []
+
+ # Update pantry for each item
+ for item in items:
+ if isinstance(item, dict):
+ name = item.get("name")
+ quantity = item.get("quantity", 0)
+ unit = item.get("unit", "")
+
+ if name:
+ # Check if ingredient exists
+ existing_ingredient = db.query(Ingredient).filter(Ingredient.name == name).first()
+ if existing_ingredient:
+ existing_ingredient.quantity += quantity
+ existing_ingredient.updated_at = datetime.utcnow()
+ else:
+ # Create new ingredient
+ new_ingredient = Ingredient(
+ name=name,
+ quantity=quantity,
+ unit=unit,
+ category=item.get("store_section"),
+ )
+ db.add(new_ingredient)
+
+ db.commit()
+ db.refresh(grocery_list)
+ return GroceryListRead.from_orm(grocery_list)
+
+
+@ai_router.post("/suggest-recipe")
+async def suggest_recipe_endpoint(db: Session = Depends(get_db)):
+ """Suggest a recipe based on current pantry."""
+ try:
+ context = pantry_service.build_pantry_context(db)
+ result = await ai_service.suggest_recipe(context)
+ return result
+ except ValueError as e:
+ raise HTTPException(status_code=503, detail=str(e))
+ except (ConnectionError, Exception) as e:
+ raise HTTPException(status_code=503, detail=f"Ollama service error: {str(e)}")
+
+
+@ai_router.get("/models")
+async def list_models():
+ """List available Ollama models."""
+ models = await ai_service.get_available_models()
+ return {"models": models}
diff --git a/routers/meals.py b/routers/meals.py
new file mode 100644
index 0000000..99c5243
--- /dev/null
+++ b/routers/meals.py
@@ -0,0 +1,97 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+from typing import List, Dict, Any
+from collections import defaultdict
+
+from database import get_db
+from models import MealLog, MealIngredient
+from schemas import MealLogCreate, MealLogRead, MealIngredientCreate
+
+router = APIRouter(prefix="/api/meals", tags=["meals"])
+
+
+@router.get("", response_model=List[MealLogRead])
+def list_meals(
+ days: int = Query(30),
+ meal_type: str = Query(None),
+ db: Session = Depends(get_db)
+):
+ """List meal log entries. Optional query params: ?days=30 (default 30, filters entries from last N days), ?meal_type=dinner. Returns entries ordered by eaten_at desc."""
+ cutoff_date = datetime.utcnow() - timedelta(days=days)
+ query = db.query(MealLog).filter(MealLog.eaten_at >= cutoff_date)
+
+ if meal_type:
+ query = query.filter(MealLog.meal_type == meal_type)
+
+ return query.order_by(MealLog.eaten_at.desc()).all()
+
+
+@router.get("/stats", response_model=Dict[str, Any])
+def meal_stats(db: Session = Depends(get_db)):
+ """Return meal frequency stats. Query last 30 days, count occurrences of each meal_name. Return {"meal_frequency": {"Pasta Carbonara": 3, ...}, "total_meals": 15, "by_type": {"dinner": 10, "lunch": 5}}."""
+ cutoff_date = datetime.utcnow() - timedelta(days=30)
+ meals = db.query(MealLog).filter(MealLog.eaten_at >= cutoff_date).all()
+
+ meal_frequency = defaultdict(int)
+ meal_by_type = defaultdict(int)
+
+ for meal in meals:
+ meal_frequency[meal.meal_name] += 1
+ meal_by_type[meal.meal_type] += 1
+
+ return {
+ "meal_frequency": dict(meal_frequency),
+ "total_meals": len(meals),
+ "by_type": dict(meal_by_type)
+ }
+
+
+@router.get("/{id}", response_model=MealLogRead)
+def get_meal(id: int, db: Session = Depends(get_db)):
+ """Get a single meal log entry."""
+ meal = db.query(MealLog).filter(MealLog.id == id).first()
+ if not meal:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Meal not found")
+ return meal
+
+
+@router.post("", response_model=MealLogRead, status_code=status.HTTP_201_CREATED)
+def create_meal(meal: MealLogCreate, db: Session = Depends(get_db)):
+ """Log a new meal. If ingredients list provided, create MealIngredient records linked to this meal."""
+ # Create the MealLog entry
+ meal_data = meal.model_dump(exclude={"ingredients"})
+ if meal_data["eaten_at"] is None:
+ meal_data["eaten_at"] = datetime.utcnow()
+
+ db_meal = MealLog(**meal_data)
+ db.add(db_meal)
+ db.commit()
+ db.refresh(db_meal)
+
+ # Create MealIngredient entries if provided
+ if meal.ingredients:
+ for ingredient in meal.ingredients:
+ meal_ingredient = MealIngredient(
+ meal_log_id=db_meal.id,
+ ingredient_name=ingredient.ingredient_name,
+ quantity_used=ingredient.quantity_used,
+ unit=ingredient.unit
+ )
+ db.add(meal_ingredient)
+
+ db.commit()
+ db.refresh(db_meal)
+
+ return db_meal
+
+
+@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_meal(id: int, db: Session = Depends(get_db)):
+ """Delete a meal log entry."""
+ meal = db.query(MealLog).filter(MealLog.id == id).first()
+ if not meal:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Meal not found")
+
+ db.delete(meal)
+ db.commit()
diff --git a/routers/menus.py b/routers/menus.py
new file mode 100644
index 0000000..49c4654
--- /dev/null
+++ b/routers/menus.py
@@ -0,0 +1,138 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta, date
+import json
+
+from database import get_db
+from schemas import MenuPlanRead
+from models import MenuPlan, Recipe
+from services import pantry_service, ai_service
+from config import settings
+
+router = APIRouter(prefix="/api/menus", tags=["menus"])
+
+
+@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())
+
+ 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")
+ return MenuPlanRead.from_orm(menu)
+
+
+@router.post("/generate")
+async def generate_menu(request: dict = None, db: Session = Depends(get_db)):
+ """Generate a weekly menu using AI."""
+ # Parse request body
+ week_start = None
+ if request and isinstance(request, dict):
+ week_start = request.get("week_start")
+
+ # Determine week_start date
+ if week_start:
+ 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())
+
+ # 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)
+ except ValueError as e:
+ raise HTTPException(status_code=503, detail=str(e))
+ except (ConnectionError, 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"]
+
+ # 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
+
+ plan_dict[day][meal_type] = recipe_id
+
+ # Upsert MenuPlan
+ existing_plan = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first()
+ if existing_plan:
+ existing_plan.plan = json.dumps(plan_dict)
+ existing_plan.generated_at = datetime.utcnow()
+ existing_plan.model_used = settings.model_name
+ menu = existing_plan
+ else:
+ menu = MenuPlan(
+ week_start=week_start,
+ plan=json.dumps(plan_dict),
+ generated_at=datetime.utcnow(),
+ model_used=settings.model_name,
+ )
+ db.add(menu)
+
+ db.commit()
+ db.refresh(menu)
+
+ return {
+ "menu_plan": MenuPlanRead.from_orm(menu),
+ "week_plan": week_plan,
+ "notes": ai_result.get("notes", ""),
+ }
+
+
+@router.get("")
+async def list_menus(db: Session = Depends(get_db)):
+ """List all menu plans."""
+ menus = db.query(MenuPlan).order_by(MenuPlan.week_start.desc()).all()
+ return [MenuPlanRead.from_orm(m) for m in menus]
+
+
+@router.get("/{week_start}")
+async def get_menu(week_start: date, db: Session = Depends(get_db)):
+ """Get menu plan for a specific week."""
+ menu = db.query(MenuPlan).filter(MenuPlan.week_start == week_start).first()
+ if not menu:
+ raise HTTPException(status_code=404, detail="Menu plan not found")
+ return MenuPlanRead.from_orm(menu)
+
+
+@router.delete("/{id}")
+async def delete_menu(id: int, db: Session = Depends(get_db)):
+ """Delete a menu plan."""
+ menu = db.query(MenuPlan).filter(MenuPlan.id == id).first()
+ if not menu:
+ raise HTTPException(status_code=404, detail="Menu plan not found")
+ db.delete(menu)
+ db.commit()
+ return {"status": "deleted"}
diff --git a/routers/pantry.py b/routers/pantry.py
new file mode 100644
index 0000000..d6638f8
--- /dev/null
+++ b/routers/pantry.py
@@ -0,0 +1,101 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+from typing import List
+
+from database import get_db
+from models import Ingredient
+from schemas import IngredientCreate, IngredientRead, IngredientUpdate
+
+router = APIRouter(prefix="/api/pantry", tags=["pantry"])
+
+
+@router.get("", response_model=List[IngredientRead])
+def list_ingredients(
+ expiring_soon: bool = Query(False),
+ db: Session = Depends(get_db)
+):
+ """List all ingredients. Optional query param ?expiring_soon=true filters to ingredients with expiry_date within 7 days from today."""
+ query = db.query(Ingredient)
+
+ if expiring_soon:
+ today = datetime.utcnow().date()
+ seven_days_later = today + timedelta(days=7)
+ query = query.filter(
+ (Ingredient.expiry_date >= today) & (Ingredient.expiry_date <= seven_days_later)
+ )
+
+ return query.all()
+
+
+@router.get("/expiring", response_model=List[IngredientRead])
+def list_expiring_ingredients(db: Session = Depends(get_db)):
+ """Ingredients expiring within 7 days."""
+ today = datetime.utcnow().date()
+ seven_days_later = today + timedelta(days=7)
+
+ return db.query(Ingredient).filter(
+ (Ingredient.expiry_date >= today) & (Ingredient.expiry_date <= seven_days_later)
+ ).all()
+
+
+@router.post("", response_model=IngredientRead, status_code=status.HTTP_201_CREATED)
+def create_ingredient(ingredient: IngredientCreate, db: Session = Depends(get_db)):
+ """Create a new ingredient."""
+ db_ingredient = Ingredient(**ingredient.model_dump())
+ db.add(db_ingredient)
+ db.commit()
+ db.refresh(db_ingredient)
+ return db_ingredient
+
+
+@router.put("/{id}", response_model=IngredientRead)
+def update_ingredient(id: int, ingredient: IngredientUpdate, db: Session = Depends(get_db)):
+ """Update ingredient fields (partial update)."""
+ db_ingredient = db.query(Ingredient).filter(Ingredient.id == id).first()
+ if not db_ingredient:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingredient not found")
+
+ update_data = ingredient.model_dump(exclude_unset=True)
+ for field, value in update_data.items():
+ setattr(db_ingredient, field, value)
+
+ db_ingredient.updated_at = datetime.utcnow()
+ db.commit()
+ db.refresh(db_ingredient)
+ return db_ingredient
+
+
+@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
+def delete_ingredient(id: int, db: Session = Depends(get_db)):
+ """Delete an ingredient."""
+ db_ingredient = db.query(Ingredient).filter(Ingredient.id == id).first()
+ if not db_ingredient:
+ raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Ingredient not found")
+
+ db.delete(db_ingredient)
+ db.commit()
+
+
+@router.post("/bulk", response_model=List[IngredientRead])
+def bulk_upsert_ingredients(ingredients: List[IngredientCreate], db: Session = Depends(get_db)):
+ """Upsert list of ingredients. If an ingredient with same name exists, add the quantity (accumulate). If not, create new."""
+ result = []
+
+ for ingredient_data in ingredients:
+ existing = db.query(Ingredient).filter(Ingredient.name == ingredient_data.name).first()
+
+ if existing:
+ existing.quantity += ingredient_data.quantity
+ existing.updated_at = datetime.utcnow()
+ db.commit()
+ db.refresh(existing)
+ result.append(existing)
+ else:
+ new_ingredient = Ingredient(**ingredient_data.model_dump())
+ db.add(new_ingredient)
+ db.commit()
+ db.refresh(new_ingredient)
+ result.append(new_ingredient)
+
+ return result
diff --git a/routers/recipes.py b/routers/recipes.py
new file mode 100644
index 0000000..f215062
--- /dev/null
+++ b/routers/recipes.py
@@ -0,0 +1,50 @@
+from fastapi import APIRouter, Depends, HTTPException, Query, status
+from sqlalchemy.orm import Session
+from typing import List
+
+from database import get_db
+from models import Recipe
+from schemas import RecipeCreate, RecipeRead, RecipeUpdate
+
+router = APIRouter(prefix="/api/recipes", tags=["recipes"])
+
+
+@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."""
+ query = db.query(Recipe)
+
+ if meal_type:
+ query = query.filter(Recipe.meal_type == meal_type)
+
+ return query.all()
+
+
+@router.post("", response_model=RecipeRead, status_code=status.HTTP_201_CREATED)
+def create_recipe(recipe: RecipeCreate, db: Session = Depends(get_db)):
+ """Create a new recipe."""
+ db_recipe = Recipe(**recipe.model_dump())
+ db.add(db_recipe)
+ db.commit()
+ db.refresh(db_recipe)
+ return db_recipe
+
+
+@router.get("/{id}", response_model=RecipeRead)
+def get_recipe(id: int, db: Session = Depends(get_db)):
+ """Get a single 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")
+ 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."""
+ 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")
+
+ db.delete(recipe)
+ db.commit()
diff --git a/schemas.py b/schemas.py
new file mode 100644
index 0000000..0d16826
--- /dev/null
+++ b/schemas.py
@@ -0,0 +1,203 @@
+from pydantic import BaseModel, ConfigDict
+from datetime import datetime, date
+from typing import Optional, List, Dict
+
+
+# Ingredient Schemas
+class IngredientCreate(BaseModel):
+ name: str
+ quantity: float
+ unit: str
+ category: Optional[str] = None
+ expiry_date: Optional[date] = None
+
+
+class IngredientRead(BaseModel):
+ id: int
+ name: str
+ quantity: float
+ unit: str
+ category: Optional[str]
+ expiry_date: Optional[date]
+ added_at: datetime
+ updated_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class IngredientUpdate(BaseModel):
+ name: Optional[str] = None
+ quantity: Optional[float] = None
+ unit: Optional[str] = None
+ category: Optional[str] = None
+ expiry_date: Optional[date] = None
+
+
+# MealIngredient Schemas
+class MealIngredientCreate(BaseModel):
+ ingredient_name: str
+ quantity_used: Optional[float] = None
+ unit: Optional[str] = None
+
+
+class MealIngredientRead(BaseModel):
+ id: int
+ meal_log_id: int
+ ingredient_name: str
+ quantity_used: Optional[float]
+ unit: Optional[str]
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class MealIngredientUpdate(BaseModel):
+ ingredient_name: Optional[str] = None
+ quantity_used: Optional[float] = None
+ unit: Optional[str] = None
+
+
+# MealLog Schemas
+class MealLogCreate(BaseModel):
+ meal_name: str
+ meal_type: str
+ eaten_at: Optional[datetime] = None
+ notes: Optional[str] = None
+ servings: int = 1
+ ingredients: Optional[List[MealIngredientCreate]] = None
+
+
+class MealLogRead(BaseModel):
+ id: int
+ meal_name: str
+ meal_type: str
+ eaten_at: datetime
+ notes: Optional[str]
+ servings: int
+ ingredients: List[MealIngredientRead]
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class MealLogUpdate(BaseModel):
+ meal_name: Optional[str] = None
+ meal_type: Optional[str] = None
+ eaten_at: Optional[datetime] = None
+ notes: Optional[str] = None
+ servings: Optional[int] = None
+
+
+# Recipe Schemas
+class RecipeCreate(BaseModel):
+ name: str
+ meal_type: str
+ ingredients: str
+ instructions: Optional[str] = None
+ estimated_time_minutes: Optional[int] = None
+ servings: int = 2
+ source: str = "ai"
+
+
+class RecipeRead(BaseModel):
+ id: int
+ name: str
+ meal_type: str
+ ingredients: str
+ instructions: Optional[str]
+ estimated_time_minutes: Optional[int]
+ servings: int
+ source: str
+ created_at: datetime
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class RecipeUpdate(BaseModel):
+ name: Optional[str] = None
+ meal_type: Optional[str] = None
+ ingredients: Optional[str] = None
+ instructions: Optional[str] = None
+ estimated_time_minutes: Optional[int] = None
+ servings: Optional[int] = None
+ source: Optional[str] = None
+
+
+# MenuPlan Schemas
+class MenuPlanCreate(BaseModel):
+ week_start: date
+ plan: str
+ model_used: Optional[str] = None
+
+
+class MenuPlanRead(BaseModel):
+ id: int
+ week_start: date
+ plan: str
+ generated_at: datetime
+ model_used: Optional[str]
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class MenuPlanUpdate(BaseModel):
+ week_start: Optional[date] = None
+ plan: Optional[str] = None
+ model_used: Optional[str] = None
+
+
+# GroceryList Schemas
+class GroceryListCreate(BaseModel):
+ generated_for: date
+ items: str
+ total_estimate: Optional[float] = None
+ notes: Optional[str] = None
+
+
+class GroceryListRead(BaseModel):
+ id: int
+ generated_for: date
+ items: str
+ total_estimate: Optional[float]
+ generated_at: datetime
+ is_purchased: bool
+ notes: Optional[str]
+
+ model_config = ConfigDict(from_attributes=True)
+
+
+class GroceryListUpdate(BaseModel):
+ generated_for: Optional[date] = None
+ items: Optional[str] = None
+ total_estimate: Optional[float] = None
+ is_purchased: Optional[bool] = None
+ notes: Optional[str] = None
+
+
+# Complex domain schemas
+class GroceryItem(BaseModel):
+ name: str
+ quantity: float
+ unit: str
+ store_section: Optional[str] = None
+ estimated_cost: Optional[float] = None
+ used_in_meals: List[str] = []
+ reason: Optional[str] = None
+
+
+class PantryContext(BaseModel):
+ available_ingredients: List[IngredientRead] = []
+ expiring_soon: List[IngredientRead] = []
+ recent_meals: List[MealLogRead] = []
+ meal_frequency: Dict[str, int] = {}
+
+
+class WeeklyMenuSlot(BaseModel):
+ name: str
+ ingredients: List[str] = []
+ instructions: Optional[str] = None
+ time_minutes: Optional[int] = None
+
+
+class WeeklyMenuDay(BaseModel):
+ breakfast: Optional[WeeklyMenuSlot] = None
+ lunch: Optional[WeeklyMenuSlot] = None
+ dinner: Optional[WeeklyMenuSlot] = None
diff --git a/services/__init__.py b/services/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/services/__init__.py
diff --git a/services/ai_service.py b/services/ai_service.py
new file mode 100644
index 0000000..2efd38f
--- /dev/null
+++ b/services/ai_service.py
@@ -0,0 +1,158 @@
+import ollama
+import json
+import asyncio
+from config import settings
+
+SYSTEM_PROMPT = """You are a professional chef assistant. You MUST respond with valid JSON only — no markdown, no explanation outside the JSON. You prioritize:
+1. Using ingredients that expire soonest
+2. Nutritional variety across the week
+3. Avoiding meals eaten in the past 7 days
+4. Practical home recipes under 60 minutes"""
+
+
+def _get_client():
+ return ollama.Client(host=settings.ollama_host)
+
+
+def _chat_sync(messages: list) -> str:
+ client = _get_client()
+ response = client.chat(
+ model=settings.model_name,
+ messages=messages,
+ format="json",
+ options={"temperature": 0.7, "num_predict": 4096},
+ )
+ return response["message"]["content"]
+
+
+async def _chat(messages: list) -> dict:
+ loop = asyncio.get_event_loop()
+ raw = await loop.run_in_executor(None, lambda: _chat_sync(messages))
+ try:
+ return json.loads(raw)
+ except json.JSONDecodeError as e:
+ raise ValueError(f"Ollama returned invalid JSON: {e}. Raw: {raw[:200]}")
+
+
+async def generate_weekly_menu(pantry_context: dict) -> dict:
+ """Generate a 7-day rotating meal plan based on pantry state."""
+ user_message = f"""Given the following pantry and recent meal history, generate a 7-day rotating meal plan.
+
+PANTRY STATE:
+{json.dumps(pantry_context, indent=2)}
+
+RULES:
+- Each day needs breakfast, lunch, and dinner
+- No meal should repeat within the same week
+- No meal should repeat a meal eaten in the last 7 days (see recent_meals)
+- Prioritize ingredients expiring within 3 days (see expiring_soon)
+- Each recipe should only use pantry ingredients OR common staples (salt, pepper, oil, etc.)
+- Keep each meal practical — under 60 minutes
+
+Respond ONLY with this JSON structure:
+{{
+ "week_plan": {{
+ "monday": {{
+ "breakfast": {{"name": "...", "ingredients": ["item1", "item2"], "instructions": "...", "time_minutes": 20}},
+ "lunch": {{"name": "...", "ingredients": [...], "instructions": "...", "time_minutes": 30}},
+ "dinner": {{"name": "...", "ingredients": [...], "instructions": "...", "time_minutes": 45}}
+ }},
+ "tuesday": {{ ... }},
+ "wednesday": {{ ... }},
+ "thursday": {{ ... }},
+ "friday": {{ ... }},
+ "saturday": {{ ... }},
+ "sunday": {{ ... }}
+ }},
+ "notes": "brief explanation of choices"
+}}"""
+
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": user_message},
+ ]
+
+ return await _chat(messages)
+
+
+async def generate_grocery_list(menu_plan: dict, pantry_context: dict) -> dict:
+ """Generate a minimal grocery list based on the menu plan and current pantry."""
+ user_message = f"""Given the weekly meal plan and current pantry, generate a minimal grocery list.
+
+MEAL PLAN:
+{json.dumps(menu_plan, indent=2)}
+
+CURRENT PANTRY:
+{json.dumps(pantry_context["available_ingredients"], indent=2)}
+
+RULES:
+- Only list ingredients NOT already sufficiently in the pantry
+- Consolidate duplicate ingredients across meals (buy once for the week)
+- Group items by store section (produce, dairy, protein, pantry, frozen, bakery)
+- Estimate realistic retail costs in USD
+- Prefer bulk when ingredient appears in 3+ meals
+
+Respond ONLY with this JSON:
+{{
+ "items": [
+ {{
+ "name": "...",
+ "quantity": 2.0,
+ "unit": "lbs",
+ "store_section": "produce",
+ "estimated_cost": 3.50,
+ "used_in_meals": ["Chicken Stir Fry", "Chicken Soup"],
+ "reason": "not in pantry"
+ }}
+ ],
+ "total_estimate": 45.00,
+ "shopping_notes": "..."
+}}"""
+
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": user_message},
+ ]
+
+ return await _chat(messages)
+
+
+async def suggest_recipe(pantry_context: dict) -> dict:
+ """Suggest one recipe using available pantry ingredients."""
+ user_message = f"""Suggest ONE recipe I can make right now using only these pantry ingredients (plus common staples).
+
+PANTRY:
+{json.dumps(pantry_context["available_ingredients"], indent=2)}
+
+Respond ONLY with this JSON:
+{{
+ "recipe": {{
+ "name": "...",
+ "meal_type": "dinner",
+ "ingredients": [{{"name": "...", "quantity": 1.0, "unit": "cup"}}],
+ "instructions": "Step 1... Step 2...",
+ "time_minutes": 30,
+ "serves": 2
+ }}
+}}"""
+
+ messages = [
+ {"role": "system", "content": SYSTEM_PROMPT},
+ {"role": "user", "content": user_message},
+ ]
+
+ return await _chat(messages)
+
+
+async def get_available_models() -> list:
+ """Get list of available Ollama model names."""
+ def _list_models_sync():
+ client = _get_client()
+ try:
+ models_resp = client.list()
+ return [m["name"] for m in models_resp.get("models", [])]
+ except Exception:
+ return []
+
+ loop = asyncio.get_event_loop()
+ return await loop.run_in_executor(None, _list_models_sync)
diff --git a/services/pantry_service.py b/services/pantry_service.py
new file mode 100644
index 0000000..19271e5
--- /dev/null
+++ b/services/pantry_service.py
@@ -0,0 +1,51 @@
+from sqlalchemy.orm import Session
+from datetime import datetime, timedelta
+from models import Ingredient, MealLog
+from collections import Counter
+
+
+def build_pantry_context(db: Session) -> dict:
+ """Return a dict with pantry state for use as AI context."""
+ today = datetime.utcnow().date()
+ soon = today + timedelta(days=3)
+
+ # All ingredients
+ all_ingredients = db.query(Ingredient).all()
+ available = [
+ {
+ "name": i.name,
+ "quantity": i.quantity,
+ "unit": i.unit,
+ "category": i.category,
+ "expiry": i.expiry_date.isoformat() if i.expiry_date else None,
+ }
+ for i in all_ingredients
+ ]
+
+ # Expiring within 3 days
+ expiring = [
+ i for i in available
+ if i["expiry"] and i["expiry"] <= soon.isoformat()
+ ]
+
+ # Recent meals (last 14 days)
+ cutoff = datetime.utcnow() - timedelta(days=14)
+ recent_meal_logs = db.query(MealLog).filter(MealLog.eaten_at >= cutoff).order_by(MealLog.eaten_at.desc()).all()
+ recent_meals = [
+ {
+ "date": m.eaten_at.date().isoformat(),
+ "meal_type": m.meal_type,
+ "meal_name": m.meal_name,
+ }
+ for m in recent_meal_logs
+ ]
+
+ # Meal frequency (count by name)
+ meal_frequency = dict(Counter(m["meal_name"] for m in recent_meals))
+
+ return {
+ "available_ingredients": available,
+ "expiring_soon": expiring,
+ "recent_meals": recent_meals,
+ "meal_frequency": meal_frequency,
+ }
diff --git a/static/app.js b/static/app.js
new file mode 100644
index 0000000..678b59b
--- /dev/null
+++ b/static/app.js
@@ -0,0 +1,757 @@
+// ── State ──────────────────────────────────────────────────────────────────
+const state = {
+ activeTab: 'pantry',
+ pantry: [],
+ meals: [],
+ mealStats: null,
+ currentMenu: null,
+ currentGrocery: null,
+ ollamaStatus: 'unknown',
+ currentGroceryId: null,
+ editingIngredientId: null,
+};
+
+// ── API Utilities ──────────────────────────────────────────────────────────
+async function api(method, path, body = null) {
+ const opts = { method, headers: { 'Content-Type': 'application/json' } };
+ if (body) opts.body = JSON.stringify(body);
+
+ try {
+ const res = await fetch(`/api${path}`, opts);
+ if (!res.ok) {
+ const err = await res.json().catch(() => ({ detail: res.statusText }));
+ throw new Error(err.detail || 'Request failed');
+ }
+ if (res.status === 204) return null;
+ return res.json();
+ } catch (err) {
+ throw err;
+ }
+}
+
+// ── Toast Notifications ───────────────────────────────────────────────────
+function showToast(message, type = 'success') {
+ const container = document.getElementById('toast-container');
+ const toast = document.createElement('div');
+ toast.className = `toast toast-${type}`;
+
+ const icon = type === 'success' ? '✓' : '✕';
+ toast.innerHTML = `
+ <div class="toast-icon">${icon}</div>
+ <div class="toast-message">${message}</div>
+ `;
+
+ container.appendChild(toast);
+
+ setTimeout(() => {
+ toast.classList.add('exiting');
+ setTimeout(() => toast.remove(), 300);
+ }, 4000);
+}
+
+// ── Formatting Utilities ───────────────────────────────────────────────────
+function formatDate(isoStr) {
+ if (!isoStr) return '';
+ const date = new Date(isoStr + 'T00:00:00Z');
+ const today = new Date();
+ const yesterday = new Date(today);
+ yesterday.setDate(yesterday.getDate() - 1);
+
+ const dateStr = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
+ const dateOnly = date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ const todayStr = today.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+ const yesterdayStr = yesterday.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
+
+ if (dateOnly === todayStr) return 'Today';
+ if (dateOnly === yesterdayStr) return 'Yesterday';
+ return dateStr;
+}
+
+function formatDateTime(isoStr) {
+ if (!isoStr) return '';
+ const date = new Date(isoStr);
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ', ' +
+ date.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+}
+
+function daysUntilExpiry(expiryDateStr) {
+ if (!expiryDateStr) return null;
+ const expiry = new Date(expiryDateStr + 'T00:00:00Z');
+ const today = new Date();
+ today.setUTCHours(0, 0, 0, 0);
+ const days = Math.floor((expiry - today) / (1000 * 60 * 60 * 24));
+ return days;
+}
+
+// ── Tab Switching ──────────────────────────────────────────────────────────
+function switchTab(tabName) {
+ state.activeTab = tabName;
+
+ // Update button states
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.classList.toggle('active', btn.dataset.tab === tabName);
+ });
+
+ // Update content visibility
+ document.querySelectorAll('.tab-content').forEach(content => {
+ content.classList.toggle('hidden', content.id !== `tab-${tabName}`);
+ content.classList.toggle('active', content.id === `tab-${tabName}`);
+ });
+
+ // Load tab content
+ if (tabName === 'pantry') loadPantry();
+ else if (tabName === 'meals') loadMeals();
+ else if (tabName === 'menu') loadMenu();
+ else if (tabName === 'grocery') loadGrocery();
+}
+
+// ── Pantry Tab ─────────────────────────────────────────────────────────────
+async function loadPantry() {
+ try {
+ state.pantry = await api('GET', '/pantry');
+ renderPantryTable();
+ updateExpiryWarning();
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+function renderPantryTable() {
+ const tbody = document.getElementById('pantry-tbody');
+ const emptyState = document.getElementById('pantry-empty');
+ const table = document.getElementById('pantry-table');
+
+ if (state.pantry.length === 0) {
+ tbody.innerHTML = '';
+ table.classList.add('hidden');
+ emptyState.classList.remove('hidden');
+ return;
+ }
+
+ table.classList.remove('hidden');
+ emptyState.classList.add('hidden');
+
+ tbody.innerHTML = state.pantry.map(ing => {
+ const days = daysUntilExpiry(ing.expiry_date);
+ let rowClass = '';
+ if (days !== null && days < 2) rowClass = 'expiry-danger';
+ else if (days !== null && days < 7) rowClass = 'expiry-warn';
+
+ const expiryText = ing.expiry_date ? formatDate(ing.expiry_date) : '—';
+
+ return `
+ <tr class="${rowClass}" data-id="${ing.id}">
+ <td><strong>${ing.name}</strong></td>
+ <td>${ing.quantity}</td>
+ <td>${ing.unit}</td>
+ <td>${ing.category || '—'}</td>
+ <td>${expiryText}</td>
+ <td>
+ <button class="btn btn-sm" onclick="editIngredient(${ing.id})">Edit</button>
+ <button class="btn btn-sm btn-danger" onclick="deleteIngredient(${ing.id})">Delete</button>
+ </td>
+ </tr>
+ `;
+ }).join('');
+}
+
+function updateExpiryWarning() {
+ const banner = document.getElementById('expiry-warning-banner');
+ const expiring = state.pantry.filter(ing => {
+ const days = daysUntilExpiry(ing.expiry_date);
+ return days !== null && days < 3 && days >= 0;
+ });
+
+ if (expiring.length === 0) {
+ banner.classList.add('hidden');
+ return;
+ }
+
+ banner.classList.remove('hidden');
+ banner.innerHTML = `
+ <strong>⚠️ Expiring Soon:</strong> ${expiring.map(ing => `${ing.name} (${formatDate(ing.expiry_date)})`).join(', ')}
+ `;
+}
+
+async function addIngredient() {
+ const name = document.getElementById('ing-name').value.trim();
+ const qty = parseFloat(document.getElementById('ing-qty').value);
+ const unit = document.getElementById('ing-unit').value.trim();
+ const category = document.getElementById('ing-category').value;
+ const expiry = document.getElementById('ing-expiry').value;
+
+ if (!name) {
+ showToast('Please enter ingredient name', 'error');
+ return;
+ }
+
+ try {
+ await api('POST', '/pantry', {
+ name,
+ quantity: qty || 0,
+ unit,
+ category: category || null,
+ expiry_date: expiry || null,
+ });
+
+ document.getElementById('add-ingredient-form').classList.add('hidden');
+ document.getElementById('ing-name').value = '';
+ document.getElementById('ing-qty').value = '';
+ document.getElementById('ing-unit').value = '';
+ document.getElementById('ing-category').value = '';
+ document.getElementById('ing-expiry').value = '';
+
+ await loadPantry();
+ showToast('Ingredient added!');
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+function editIngredient(id) {
+ const ing = state.pantry.find(i => i.id === id);
+ if (!ing) return;
+
+ state.editingIngredientId = id;
+ document.getElementById('ing-name').value = ing.name;
+ document.getElementById('ing-qty').value = ing.quantity || '';
+ document.getElementById('ing-unit').value = ing.unit || '';
+ document.getElementById('ing-category').value = ing.category || '';
+ document.getElementById('ing-expiry').value = ing.expiry_date || '';
+ document.getElementById('add-ingredient-form').classList.remove('hidden');
+ document.getElementById('btn-save-ingredient').textContent = 'Update';
+}
+
+async function deleteIngredient(id) {
+ if (!confirm('Delete this ingredient?')) return;
+
+ try {
+ await api('DELETE', `/pantry/${id}`);
+ await loadPantry();
+ showToast('Ingredient deleted');
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+// ── Meals Tab ──────────────────────────────────────────────────────────────
+async function loadMeals() {
+ try {
+ state.meals = await api('GET', '/meals?days=30');
+ state.mealStats = await api('GET', '/meals/stats');
+ renderMealsStats();
+ renderMealsTimeline();
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+function renderMealsStats() {
+ const statsDiv = document.getElementById('meals-stats');
+
+ if (!state.mealStats || !state.mealStats.top_meals || state.mealStats.top_meals.length === 0) {
+ statsDiv.innerHTML = '';
+ return;
+ }
+
+ const maxCount = Math.max(...state.mealStats.top_meals.map(m => m.count));
+
+ statsDiv.innerHTML = `
+ <div class="stats-title">Top Meals (Last 30 Days)</div>
+ ${state.mealStats.top_meals.slice(0, 5).map(meal => {
+ const percentage = (meal.count / maxCount) * 100;
+ return `
+ <div class="stat-item">
+ <div class="stat-label">${meal.meal_name}</div>
+ <div class="stat-bar">
+ <div class="stat-bar-fill" style="width: ${percentage}%">${meal.count}x</div>
+ </div>
+ <div class="stat-count">${meal.count}</div>
+ </div>
+ `;
+ }).join('')}
+ `;
+}
+
+function renderMealsTimeline() {
+ const timelineDiv = document.getElementById('meals-timeline');
+ const emptyState = document.getElementById('meals-empty');
+
+ if (state.meals.length === 0) {
+ timelineDiv.innerHTML = '';
+ emptyState.classList.remove('hidden');
+ return;
+ }
+
+ emptyState.classList.add('hidden');
+
+ // Group meals by date
+ const grouped = {};
+ state.meals.forEach(meal => {
+ const dateKey = meal.eaten_at.split('T')[0];
+ if (!grouped[dateKey]) grouped[dateKey] = [];
+ grouped[dateKey].push(meal);
+ });
+
+ // Sort dates descending
+ const sortedDates = Object.keys(grouped).sort().reverse();
+
+ timelineDiv.innerHTML = `
+ <div class="meals-timeline">
+ ${sortedDates.map(dateKey => {
+ const meals = grouped[dateKey];
+ const dateLabel = formatDate(dateKey);
+ return `
+ <div class="meal-date-group">
+ <div class="meal-date-label">${dateLabel}</div>
+ ${meals.map(meal => {
+ const badgeClass = `meal-type-${meal.meal_type}`;
+ const timeStr = new Date(meal.eaten_at).toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
+ return `
+ <div class="meal-entry">
+ <div class="meal-info">
+ <div class="meal-type-badge ${badgeClass}">${meal.meal_type}</div>
+ <div class="meal-name">${meal.meal_name}</div>
+ <div class="meal-time">${timeStr}</div>
+ ${meal.notes ? `<div class="meal-notes">${meal.notes}</div>` : ''}
+ </div>
+ <button class="btn btn-sm btn-danger" onclick="deleteMeal(${meal.id})">Delete</button>
+ </div>
+ `;
+ }).join('')}
+ </div>
+ `;
+ }).join('')}
+ </div>
+ `;
+}
+
+async function logMeal() {
+ const mealName = document.getElementById('meal-name').value.trim();
+ const mealType = document.getElementById('meal-type').value;
+ const eatenAt = document.getElementById('meal-datetime').value;
+ const notes = document.getElementById('meal-notes').value.trim();
+ const servings = parseInt(document.getElementById('meal-servings').value) || 1;
+
+ if (!mealName || !eatenAt) {
+ showToast('Please fill meal name and date/time', 'error');
+ return;
+ }
+
+ try {
+ await api('POST', '/meals', {
+ meal_name: mealName,
+ meal_type: mealType,
+ eaten_at: eatenAt,
+ notes: notes || null,
+ servings,
+ });
+
+ document.getElementById('log-meal-form').classList.add('hidden');
+ document.getElementById('meal-name').value = '';
+ document.getElementById('meal-notes').value = '';
+ document.getElementById('meal-servings').value = '1';
+
+ await loadMeals();
+ showToast('Meal logged!');
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+async function deleteMeal(id) {
+ if (!confirm('Delete this meal log?')) return;
+
+ try {
+ await api('DELETE', `/meals/${id}`);
+ await loadMeals();
+ showToast('Meal deleted');
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+// ── Menu Tab ───────────────────────────────────────────────────────────────
+async function loadMenu() {
+ try {
+ const res = await api('GET', '/menus/current').catch(err => {
+ if (err.message.includes('404')) return null;
+ throw err;
+ });
+
+ if (!res) {
+ document.getElementById('menu-grid').innerHTML = '';
+ document.getElementById('menu-empty').classList.remove('hidden');
+ document.getElementById('menu-notes').classList.add('hidden');
+ return;
+ }
+
+ state.currentMenu = res;
+ document.getElementById('menu-empty').classList.add('hidden');
+
+ if (res.notes) {
+ document.getElementById('menu-notes').textContent = res.notes;
+ document.getElementById('menu-notes').classList.remove('hidden');
+ }
+
+ renderMenuGrid();
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+function renderMenuGrid() {
+ const grid = document.getElementById('menu-grid');
+ if (!state.currentMenu) return;
+
+ const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
+ const mealTypes = ['breakfast', 'lunch', 'dinner'];
+
+ let plan = state.currentMenu.plan;
+ if (typeof plan === 'string') {
+ try {
+ plan = JSON.parse(plan);
+ } catch {
+ plan = {};
+ }
+ }
+
+ let weekPlan = state.currentMenu.week_plan;
+ if (typeof weekPlan === 'string') {
+ try {
+ weekPlan = JSON.parse(weekPlan);
+ } catch {
+ weekPlan = {};
+ }
+ }
+
+ // Header row
+ let html = '<div class="menu-grid-header"></div>';
+ days.forEach(day => {
+ html += `<div class="menu-grid-header">${day}</div>`;
+ });
+
+ // Meal type rows
+ mealTypes.forEach(mealType => {
+ days.forEach(day => {
+ const dayKey = day.toLowerCase();
+ const dayPlan = plan[dayKey] || {};
+ const recipeId = dayPlan[mealType];
+
+ let recipeName = '—';
+ let recipeTime = '';
+ let repeatWarning = false;
+
+ if (weekPlan && weekPlan[dayKey] && weekPlan[dayKey][mealType]) {
+ const recipe = weekPlan[dayKey][mealType];
+ recipeName = recipe.name || recipe.recipe_name || '—';
+ recipeTime = recipe.time_minutes ? `${recipe.time_minutes} min` : '';
+ repeatWarning = recipe.repeat_warning || false;
+ }
+
+ const typeDisplay = mealType.charAt(0).toUpperCase() + mealType.slice(1);
+ const warningClass = repeatWarning ? 'repeat-warning' : '';
+
+ html += `
+ <div class="menu-grid-cell ${warningClass}">
+ <div>
+ <div class="menu-meal-type">${typeDisplay}</div>
+ <div class="menu-meal-name">${recipeName}</div>
+ ${recipeTime ? `<div class="menu-time">${recipeTime}</div>` : ''}
+ </div>
+ ${recipeName !== '—' ? `<button class="btn btn-sm btn-primary" onclick="makeThisMeal('${recipeName.replace(/'/g, "\\'")}', '${mealType}')">Make This</button>` : ''}
+ </div>
+ `;
+ });
+ });
+
+ grid.innerHTML = html;
+}
+
+async function generateMenu() {
+ const btn = document.getElementById('btn-generate-menu');
+ const spinner = document.getElementById('menu-spinner');
+
+ btn.disabled = true;
+ spinner.classList.remove('hidden');
+
+ try {
+ const res = await api('POST', '/menus/generate', {});
+ state.currentMenu = res;
+ document.getElementById('menu-empty').classList.add('hidden');
+
+ if (res.notes) {
+ document.getElementById('menu-notes').textContent = res.notes;
+ document.getElementById('menu-notes').classList.remove('hidden');
+ }
+
+ renderMenuGrid();
+ showToast('Menu generated!');
+ } catch (err) {
+ if (err.message.includes('503')) {
+ showToast('Ollama is offline or not responding', 'error');
+ } else {
+ showToast(err.message, 'error');
+ }
+ } finally {
+ btn.disabled = false;
+ spinner.classList.add('hidden');
+ }
+}
+
+async function makeThisMeal(mealName, mealType) {
+ try {
+ const now = new Date();
+ await api('POST', '/meals', {
+ meal_name: mealName,
+ meal_type: mealType,
+ eaten_at: now.toISOString(),
+ notes: null,
+ servings: 1,
+ });
+ showToast(`Logged: ${mealName}`);
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+// ── Grocery Tab ────────────────────────────────────────────────────────────
+async function loadGrocery() {
+ try {
+ const res = await api('GET', '/grocery/current').catch(err => {
+ if (err.message.includes('404')) return null;
+ throw err;
+ });
+
+ if (!res) {
+ document.getElementById('grocery-sections').innerHTML = '';
+ document.getElementById('grocery-total').classList.add('hidden');
+ document.getElementById('grocery-empty').classList.remove('hidden');
+ document.getElementById('btn-mark-purchased').classList.add('hidden');
+ return;
+ }
+
+ state.currentGrocery = res;
+ state.currentGroceryId = res.id;
+ document.getElementById('grocery-empty').classList.add('hidden');
+
+ if (!res.is_purchased) {
+ document.getElementById('btn-mark-purchased').classList.remove('hidden');
+ } else {
+ document.getElementById('btn-mark-purchased').classList.add('hidden');
+ }
+
+ renderGroceryList();
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+function renderGroceryList() {
+ const sectionsDiv = document.getElementById('grocery-sections');
+ const totalDiv = document.getElementById('grocery-total');
+
+ 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 = [];
+
+ // Group by store_section
+ const grouped = {};
+ items.forEach(item => {
+ const section = item.store_section || 'Other';
+ if (!grouped[section]) grouped[section] = [];
+ grouped[section].push(item);
+ });
+
+ // Render sections
+ let html = '';
+ Object.keys(grouped).sort().forEach(section => {
+ const titleCased = section.charAt(0).toUpperCase() + section.slice(1);
+ html += `<div class="grocery-section">
+ <div class="grocery-section-title">${titleCased}</div>
+ ${grouped[section].map(item => {
+ const usedIn = item.used_in ? item.used_in.join(', ') : '';
+ return `
+ <div class="grocery-item">
+ <input type="checkbox">
+ <div class="grocery-item-details">
+ <div class="grocery-item-name">${item.name}</div>
+ <div class="grocery-item-meta">
+ ${item.quantity} ${item.unit}
+ ${item.estimated_cost ? ` - $${item.estimated_cost.toFixed(2)}` : ''}
+ ${usedIn ? ` - Used in: ${usedIn}` : ''}
+ </div>
+ </div>
+ </div>
+ `;
+ }).join('')}
+ </div>`;
+ });
+
+ sectionsDiv.innerHTML = html;
+
+ // Show total
+ const total = items.reduce((sum, item) => sum + (item.estimated_cost || 0), 0);
+ if (total > 0) {
+ totalDiv.innerHTML = `
+ <div class="grocery-total-label">Estimated Total</div>
+ <div class="grocery-total-amount">$${total.toFixed(2)}</div>
+ `;
+ totalDiv.classList.remove('hidden');
+ }
+}
+
+async function generateGrocery() {
+ const btn = document.getElementById('btn-generate-grocery');
+ const spinner = document.getElementById('grocery-spinner');
+
+ btn.disabled = true;
+ spinner.classList.remove('hidden');
+
+ try {
+ const res = await api('POST', '/grocery/generate', {});
+ state.currentGrocery = res;
+ state.currentGroceryId = res.id;
+ document.getElementById('grocery-empty').classList.add('hidden');
+ document.getElementById('btn-mark-purchased').classList.remove('hidden');
+ renderGroceryList();
+ showToast('Grocery list generated!');
+ } catch (err) {
+ if (err.message.includes('not found') || err.message.includes('No menu')) {
+ showToast('Generate a weekly menu first', 'error');
+ } else if (err.message.includes('503')) {
+ showToast('Ollama is offline or not responding', 'error');
+ } else {
+ showToast(err.message, 'error');
+ }
+ } finally {
+ btn.disabled = false;
+ spinner.classList.add('hidden');
+ }
+}
+
+async function markPurchased() {
+ try {
+ await api('PUT', `/grocery/${state.currentGroceryId}/purchased`, {});
+ await loadPantry();
+ await loadGrocery();
+ showToast('Pantry updated!');
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+}
+
+// ── Initialization ─────────────────────────────────────────────────────────
+async function init() {
+ // Check Ollama status
+ try {
+ const health = await api('GET', '/health');
+ const badge = document.getElementById('ollama-status');
+ if (health.ollama === 'connected') {
+ badge.textContent = `✓ ${health.model}`;
+ badge.className = 'status-badge status-ok';
+ } else {
+ badge.textContent = '✗ Ollama offline';
+ badge.className = 'status-badge status-error';
+ }
+ state.ollamaStatus = health.ollama;
+ } catch {
+ document.getElementById('ollama-status').textContent = '✗ Ollama offline';
+ document.getElementById('ollama-status').className = 'status-badge status-error';
+ }
+
+ // Set up tab switching
+ document.querySelectorAll('.tab-btn').forEach(btn => {
+ btn.addEventListener('click', () => switchTab(btn.dataset.tab));
+ });
+
+ // Set up pantry form
+ document.getElementById('btn-add-ingredient').addEventListener('click', () => {
+ state.editingIngredientId = null;
+ document.getElementById('ing-name').value = '';
+ document.getElementById('ing-qty').value = '';
+ document.getElementById('ing-unit').value = '';
+ document.getElementById('ing-category').value = '';
+ document.getElementById('ing-expiry').value = '';
+ document.getElementById('btn-save-ingredient').textContent = 'Save';
+ document.getElementById('add-ingredient-form').classList.remove('hidden');
+ });
+
+ document.getElementById('btn-cancel-ingredient').addEventListener('click', () => {
+ document.getElementById('add-ingredient-form').classList.add('hidden');
+ });
+
+ document.getElementById('btn-save-ingredient').addEventListener('click', async () => {
+ if (state.editingIngredientId) {
+ const ing = state.pantry.find(i => i.id === state.editingIngredientId);
+ if (!ing) return;
+
+ const name = document.getElementById('ing-name').value.trim();
+ const qty = parseFloat(document.getElementById('ing-qty').value);
+ const unit = document.getElementById('ing-unit').value.trim();
+ const category = document.getElementById('ing-category').value;
+ const expiry = document.getElementById('ing-expiry').value;
+
+ if (!name) {
+ showToast('Please enter ingredient name', 'error');
+ return;
+ }
+
+ try {
+ await api('PUT', `/pantry/${state.editingIngredientId}`, {
+ name,
+ quantity: qty || 0,
+ unit,
+ category: category || null,
+ expiry_date: expiry || null,
+ });
+
+ document.getElementById('add-ingredient-form').classList.add('hidden');
+ await loadPantry();
+ showToast('Ingredient updated!');
+ } catch (err) {
+ showToast(err.message, 'error');
+ }
+ } else {
+ addIngredient();
+ }
+ });
+
+ // Set up meals form
+ document.getElementById('btn-log-meal').addEventListener('click', () => {
+ const now = new Date();
+ document.getElementById('meal-datetime').value = now.toISOString().slice(0, 16);
+ document.getElementById('meal-name').value = '';
+ document.getElementById('meal-type').value = 'dinner';
+ document.getElementById('meal-notes').value = '';
+ document.getElementById('meal-servings').value = '1';
+ document.getElementById('log-meal-form').classList.remove('hidden');
+ });
+
+ document.getElementById('btn-cancel-meal').addEventListener('click', () => {
+ document.getElementById('log-meal-form').classList.add('hidden');
+ });
+
+ document.getElementById('btn-save-meal').addEventListener('click', logMeal);
+
+ // Set up menu form
+ document.getElementById('btn-generate-menu').addEventListener('click', generateMenu);
+
+ // Set up grocery form
+ document.getElementById('btn-generate-grocery').addEventListener('click', generateGrocery);
+ document.getElementById('btn-mark-purchased').addEventListener('click', markPurchased);
+
+ // Load initial tab
+ await loadPantry();
+}
+
+document.addEventListener('DOMContentLoaded', init);
diff --git a/static/index.html b/static/index.html
new file mode 100644
index 0000000..a9ae018
--- /dev/null
+++ b/static/index.html
@@ -0,0 +1,127 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="UTF-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
+ <title>Commis</title>
+ <link rel="stylesheet" href="style.css">
+</head>
+<body>
+ <header>
+ <div class="header-title">🔪 Commis</div>
+ <div id="ollama-status" class="status-badge">Checking...</div>
+ </header>
+
+ <nav class="tab-nav">
+ <button class="tab-btn active" data-tab="pantry">Pantry</button>
+ <button class="tab-btn" data-tab="meals">Meals</button>
+ <button class="tab-btn" data-tab="menu">This Week's Menu</button>
+ <button class="tab-btn" data-tab="grocery">Grocery List</button>
+ </nav>
+
+ <main>
+ <!-- Pantry Tab -->
+ <div id="tab-pantry" class="tab-content active">
+ <div class="tab-header">
+ <h2>Pantry</h2>
+ <button class="btn btn-primary" id="btn-add-ingredient">+ Add Ingredient</button>
+ </div>
+ <!-- Add form (hidden by default) -->
+ <div id="add-ingredient-form" class="form-card hidden">
+ <h3>Add Ingredient</h3>
+ <div class="form-row">
+ <div class="form-group"><label>Name</label><input type="text" id="ing-name" placeholder="e.g. Chicken Breast"></div>
+ <div class="form-group"><label>Quantity</label><input type="number" id="ing-qty" step="0.1" placeholder="500"></div>
+ <div class="form-group"><label>Unit</label><input type="text" id="ing-unit" placeholder="grams"></div>
+ <div class="form-group"><label>Category</label>
+ <select id="ing-category">
+ <option value="">None</option>
+ <option value="produce">Produce</option>
+ <option value="protein">Protein</option>
+ <option value="dairy">Dairy</option>
+ <option value="pantry">Pantry</option>
+ <option value="frozen">Frozen</option>
+ <option value="bakery">Bakery</option>
+ </select>
+ </div>
+ <div class="form-group"><label>Expiry Date</label><input type="date" id="ing-expiry"></div>
+ </div>
+ <div class="form-actions">
+ <button class="btn btn-primary" id="btn-save-ingredient">Save</button>
+ <button class="btn" id="btn-cancel-ingredient">Cancel</button>
+ </div>
+ </div>
+ <div id="expiry-warning-banner" class="hidden"></div>
+ <table id="pantry-table">
+ <thead><tr><th>Name</th><th>Quantity</th><th>Unit</th><th>Category</th><th>Expires</th><th>Actions</th></tr></thead>
+ <tbody id="pantry-tbody"></tbody>
+ </table>
+ <div id="pantry-empty" class="empty-state hidden">No ingredients yet. Add some to get started.</div>
+ </div>
+
+ <!-- Meals Tab -->
+ <div id="tab-meals" class="tab-content hidden">
+ <div class="tab-header">
+ <h2>Meal Log</h2>
+ <button class="btn btn-primary" id="btn-log-meal">+ Log Meal</button>
+ </div>
+ <div id="log-meal-form" class="form-card hidden">
+ <h3>Log a Meal</h3>
+ <div class="form-row">
+ <div class="form-group"><label>Meal Name</label><input type="text" id="meal-name" placeholder="e.g. Chicken Stir Fry"></div>
+ <div class="form-group"><label>Type</label>
+ <select id="meal-type">
+ <option value="breakfast">Breakfast</option>
+ <option value="lunch">Lunch</option>
+ <option value="dinner" selected>Dinner</option>
+ <option value="snack">Snack</option>
+ </select>
+ </div>
+ <div class="form-group"><label>Date &amp; Time</label><input type="datetime-local" id="meal-datetime"></div>
+ <div class="form-group"><label>Servings</label><input type="number" id="meal-servings" value="1" min="1"></div>
+ <div class="form-group full-width"><label>Notes</label><input type="text" id="meal-notes" placeholder="Optional notes"></div>
+ </div>
+ <div class="form-actions">
+ <button class="btn btn-primary" id="btn-save-meal">Save</button>
+ <button class="btn" id="btn-cancel-meal">Cancel</button>
+ </div>
+ </div>
+ <div id="meals-stats" class="stats-section"></div>
+ <div id="meals-timeline"></div>
+ <div id="meals-empty" class="empty-state hidden">No meals logged yet.</div>
+ </div>
+
+ <!-- Menu Tab -->
+ <div id="tab-menu" class="tab-content hidden">
+ <div class="tab-header">
+ <h2>This Week's Menu</h2>
+ <div class="header-actions">
+ <button class="btn btn-primary" id="btn-generate-menu">✨ Generate Menu</button>
+ <div id="menu-spinner" class="spinner hidden"></div>
+ </div>
+ </div>
+ <div id="menu-notes" class="info-banner hidden"></div>
+ <div id="menu-grid" class="menu-grid"></div>
+ <div id="menu-empty" class="empty-state hidden">No menu generated yet. Click "Generate Menu" to create one.</div>
+ </div>
+
+ <!-- Grocery Tab -->
+ <div id="tab-grocery" class="tab-content hidden">
+ <div class="tab-header">
+ <h2>Grocery List</h2>
+ <div class="header-actions">
+ <button class="btn btn-primary" id="btn-generate-grocery">✨ Generate List</button>
+ <button class="btn btn-success hidden" id="btn-mark-purchased">✓ Mark All Purchased</button>
+ <div id="grocery-spinner" class="spinner hidden"></div>
+ </div>
+ </div>
+ <div id="grocery-total" class="hidden"></div>
+ <div id="grocery-sections"></div>
+ <div id="grocery-empty" class="empty-state hidden">No grocery list yet. Generate a menu first, then generate the grocery list.</div>
+ </div>
+ </main>
+
+ <div id="toast-container"></div>
+ <script src="app.js"></script>
+</body>
+</html>
diff --git a/static/style.css b/static/style.css
new file mode 100644
index 0000000..18908c7
--- /dev/null
+++ b/static/style.css
@@ -0,0 +1,823 @@
+:root {
+ --bg: #0f1117;
+ --surface: #1a1d27;
+ --surface2: #252836;
+ --border: #2e3148;
+ --accent: #6c63ff;
+ --accent-hover: #8b84ff;
+ --text: #e2e8f0;
+ --text-muted: #94a3b8;
+ --success: #22c55e;
+ --warning: #f59e0b;
+ --danger: #ef4444;
+ --info: #3b82f6;
+}
+
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+ background-color: var(--bg);
+ color: var(--text);
+ line-height: 1.6;
+}
+
+/* ── Header ─────────────────────────────────────────────────────────────── */
+header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 1.5rem 2rem;
+ background-color: var(--surface);
+ border-bottom: 1px solid var(--border);
+ position: sticky;
+ top: 0;
+ z-index: 100;
+}
+
+.header-title {
+ font-size: 1.5rem;
+ font-weight: 600;
+ letter-spacing: 0.5px;
+}
+
+.status-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 0.5rem;
+ padding: 0.5rem 1rem;
+ border-radius: 1rem;
+ background-color: var(--surface2);
+ border: 1px solid var(--border);
+ font-size: 0.875rem;
+ font-weight: 500;
+}
+
+.status-ok {
+ border-color: var(--success);
+ color: var(--success);
+}
+
+.status-error {
+ border-color: var(--danger);
+ color: var(--danger);
+}
+
+/* ── Tab Navigation ───────────────────────────────────────────────────────── */
+.tab-nav {
+ display: flex;
+ gap: 0.5rem;
+ padding: 1rem 2rem;
+ background-color: var(--surface);
+ border-bottom: 1px solid var(--border);
+ overflow-x: auto;
+}
+
+.tab-btn {
+ padding: 0.75rem 1.5rem;
+ background-color: transparent;
+ border: 1px solid var(--border);
+ border-radius: 2rem;
+ color: var(--text-muted);
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+}
+
+.tab-btn:hover {
+ border-color: var(--accent);
+ color: var(--text);
+}
+
+.tab-btn.active {
+ background-color: var(--accent);
+ border-color: var(--accent);
+ color: var(--bg);
+}
+
+/* ── Main Content ───────────────────────────────────────────────────────── */
+main {
+ max-width: 1400px;
+ margin: 0 auto;
+ padding: 2rem;
+}
+
+.tab-content {
+ display: none;
+}
+
+.tab-content.active {
+ display: block;
+ animation: fadeIn 0.2s ease;
+}
+
+@keyframes fadeIn {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.tab-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 2rem;
+}
+
+.tab-header h2 {
+ font-size: 1.875rem;
+ font-weight: 600;
+}
+
+.header-actions {
+ display: flex;
+ gap: 1rem;
+ align-items: center;
+}
+
+/* ── Forms ──────────────────────────────────────────────────────────────── */
+.form-card {
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.form-card h3 {
+ margin-bottom: 1.5rem;
+ font-size: 1.25rem;
+}
+
+.form-row {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
+ gap: 1.5rem;
+ margin-bottom: 1.5rem;
+}
+
+.form-group {
+ display: flex;
+ flex-direction: column;
+}
+
+.form-group.full-width {
+ grid-column: 1 / -1;
+}
+
+.form-group label {
+ font-size: 0.875rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ color: var(--text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+.form-group input,
+.form-group select {
+ padding: 0.75rem;
+ background-color: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ color: var(--text);
+ font-size: 0.9rem;
+ transition: border-color 0.2s ease;
+}
+
+.form-group input:focus,
+.form-group select:focus {
+ outline: none;
+ border-color: var(--accent);
+ background-color: rgba(108, 99, 255, 0.05);
+}
+
+.form-actions {
+ display: flex;
+ gap: 1rem;
+ justify-content: flex-end;
+}
+
+/* ── Buttons ────────────────────────────────────────────────────────────── */
+.btn {
+ padding: 0.75rem 1.5rem;
+ background-color: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 0.375rem;
+ color: var(--text);
+ font-size: 0.9rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.2s ease;
+ white-space: nowrap;
+}
+
+.btn:hover {
+ background-color: var(--border);
+ border-color: var(--text-muted);
+}
+
+.btn:active {
+ transform: scale(0.98);
+}
+
+.btn-primary {
+ background-color: var(--accent);
+ border-color: var(--accent);
+ color: var(--bg);
+}
+
+.btn-primary:hover {
+ background-color: var(--accent-hover);
+ border-color: var(--accent-hover);
+}
+
+.btn-primary:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+ transform: none;
+}
+
+.btn-danger {
+ background-color: var(--danger);
+ border-color: var(--danger);
+ color: white;
+}
+
+.btn-danger:hover {
+ background-color: #dc2626;
+ border-color: #dc2626;
+}
+
+.btn-success {
+ background-color: var(--success);
+ border-color: var(--success);
+ color: var(--bg);
+}
+
+.btn-success:hover {
+ background-color: #16a34a;
+ border-color: #16a34a;
+}
+
+.btn-sm {
+ padding: 0.5rem 1rem;
+ font-size: 0.8rem;
+}
+
+/* ── Tables ────────────────────────────────────────────────────────────── */
+table {
+ width: 100%;
+ border-collapse: collapse;
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ overflow: hidden;
+}
+
+thead {
+ background-color: var(--surface2);
+ border-bottom: 1px solid var(--border);
+}
+
+th {
+ padding: 1rem;
+ text-align: left;
+ font-weight: 600;
+ color: var(--text-muted);
+ font-size: 0.875rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+}
+
+td {
+ padding: 1rem;
+ border-top: 1px solid var(--border);
+ font-size: 0.9rem;
+}
+
+tbody tr:nth-child(odd) {
+ background-color: rgba(255, 255, 255, 0.01);
+}
+
+tbody tr:hover {
+ background-color: rgba(108, 99, 255, 0.1);
+}
+
+tbody tr.expiry-danger {
+ background-color: rgba(239, 68, 68, 0.1);
+ border-left: 3px solid var(--danger);
+}
+
+tbody tr.expiry-warn {
+ background-color: rgba(245, 158, 11, 0.1);
+ border-left: 3px solid var(--warning);
+}
+
+/* ── Cards ──────────────────────────────────────────────────────────────── */
+.card {
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ transition: all 0.2s ease;
+}
+
+.card:hover {
+ border-color: var(--accent);
+ box-shadow: 0 0 0 3px rgba(108, 99, 255, 0.1);
+}
+
+.card-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+}
+
+.card-subtitle {
+ font-size: 0.875rem;
+ color: var(--text-muted);
+ margin-bottom: 1rem;
+}
+
+.card-body {
+ font-size: 0.9rem;
+}
+
+/* ── Menu Grid ──────────────────────────────────────────────────────────── */
+.menu-grid {
+ display: grid;
+ grid-template-columns: repeat(8, 1fr);
+ gap: 0.5rem;
+ margin-bottom: 2rem;
+}
+
+.menu-grid-cell {
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 1rem;
+ min-height: 120px;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ transition: all 0.2s ease;
+}
+
+.menu-grid-cell:hover {
+ border-color: var(--accent);
+}
+
+.menu-grid-cell.repeat-warning {
+ border: 2px solid var(--warning);
+}
+
+.menu-grid-header {
+ background-color: var(--surface2);
+ font-weight: 600;
+ text-align: center;
+ padding: 1rem;
+ border-radius: 0.5rem;
+ border: 1px solid var(--border);
+}
+
+.menu-meal-type {
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ color: var(--text-muted);
+ margin-bottom: 0.5rem;
+}
+
+.menu-meal-name {
+ font-weight: 600;
+ margin-bottom: 0.5rem;
+ word-break: break-word;
+}
+
+.menu-time {
+ display: inline-block;
+ font-size: 0.75rem;
+ background-color: var(--surface2);
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ margin-bottom: 0.75rem;
+ color: var(--text-muted);
+}
+
+.menu-actions {
+ display: flex;
+ gap: 0.5rem;
+ flex-wrap: wrap;
+}
+
+@media (max-width: 768px) {
+ .menu-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .menu-grid-header {
+ display: none;
+ }
+
+ .menu-grid-cell {
+ position: relative;
+ }
+
+ .menu-grid-cell::before {
+ content: attr(data-day);
+ position: absolute;
+ top: 0.5rem;
+ right: 0.5rem;
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ background-color: var(--surface2);
+ padding: 0.25rem 0.5rem;
+ border-radius: 0.25rem;
+ }
+}
+
+/* ── Grocery List ───────────────────────────────────────────────────────── */
+.grocery-section {
+ margin-bottom: 2rem;
+}
+
+.grocery-section-title {
+ font-weight: 600;
+ font-size: 1.125rem;
+ margin-bottom: 1rem;
+ padding-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border);
+ text-transform: capitalize;
+}
+
+.grocery-item {
+ display: flex;
+ align-items: flex-start;
+ gap: 1rem;
+ padding: 0.75rem;
+ background-color: rgba(255, 255, 255, 0.01);
+ border-radius: 0.375rem;
+ margin-bottom: 0.5rem;
+ font-size: 0.9rem;
+}
+
+.grocery-item input[type="checkbox"] {
+ margin-top: 0.25rem;
+ cursor: pointer;
+ width: 18px;
+ height: 18px;
+ accent-color: var(--accent);
+}
+
+.grocery-item-details {
+ flex: 1;
+}
+
+.grocery-item-name {
+ font-weight: 500;
+ margin-bottom: 0.25rem;
+}
+
+.grocery-item-meta {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.grocery-total {
+ background-color: var(--surface2);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+ text-align: center;
+}
+
+.grocery-total-label {
+ font-size: 0.9rem;
+ color: var(--text-muted);
+ margin-bottom: 0.5rem;
+ text-transform: uppercase;
+ letter-spacing: 0.5px;
+ font-weight: 600;
+}
+
+.grocery-total-amount {
+ font-size: 2rem;
+ font-weight: 700;
+ color: var(--accent);
+}
+
+/* ── Stats Bar Chart ────────────────────────────────────────────────────── */
+.stats-section {
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 1.5rem;
+ margin-bottom: 2rem;
+}
+
+.stats-title {
+ font-size: 1.125rem;
+ font-weight: 600;
+ margin-bottom: 1.5rem;
+}
+
+.stat-item {
+ display: grid;
+ grid-template-columns: 150px 1fr 80px;
+ gap: 1rem;
+ align-items: center;
+ margin-bottom: 1rem;
+}
+
+.stat-label {
+ font-size: 0.9rem;
+ font-weight: 500;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.stat-bar {
+ display: flex;
+ align-items: center;
+ height: 28px;
+ background-color: var(--surface2);
+ border-radius: 0.375rem;
+ overflow: hidden;
+}
+
+.stat-bar-fill {
+ background: linear-gradient(90deg, var(--accent), var(--accent-hover));
+ height: 100%;
+ display: flex;
+ align-items: center;
+ justify-content: flex-end;
+ padding-right: 0.5rem;
+ color: var(--bg);
+ font-size: 0.8rem;
+ font-weight: 600;
+ transition: width 0.3s ease;
+}
+
+.stat-count {
+ font-size: 0.9rem;
+ font-weight: 600;
+ text-align: right;
+ color: var(--text-muted);
+}
+
+/* ── Meals Timeline ────────────────────────────────────────────────────── */
+.meals-timeline {
+ display: flex;
+ flex-direction: column;
+ gap: 2rem;
+}
+
+.meal-date-group {
+ border-left: 3px solid var(--accent);
+ padding-left: 1.5rem;
+}
+
+.meal-date-label {
+ font-weight: 600;
+ font-size: 1rem;
+ margin-bottom: 1rem;
+ color: var(--accent);
+}
+
+.meal-entry {
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 0.75rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: flex-start;
+ gap: 1rem;
+}
+
+.meal-type-badge {
+ display: inline-block;
+ padding: 0.25rem 0.75rem;
+ border-radius: 0.25rem;
+ font-size: 0.75rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ margin-bottom: 0.5rem;
+}
+
+.meal-type-breakfast {
+ background-color: rgba(245, 158, 11, 0.2);
+ color: var(--warning);
+}
+
+.meal-type-lunch {
+ background-color: rgba(108, 99, 255, 0.2);
+ color: var(--accent);
+}
+
+.meal-type-dinner {
+ background-color: rgba(34, 197, 94, 0.2);
+ color: var(--success);
+}
+
+.meal-type-snack {
+ background-color: rgba(59, 130, 246, 0.2);
+ color: var(--info);
+}
+
+.meal-info {
+ flex: 1;
+}
+
+.meal-name {
+ font-weight: 600;
+ margin-bottom: 0.25rem;
+}
+
+.meal-time {
+ font-size: 0.8rem;
+ color: var(--text-muted);
+}
+
+.meal-notes {
+ font-size: 0.85rem;
+ color: var(--text-muted);
+ margin-top: 0.5rem;
+ font-style: italic;
+}
+
+/* ── Spinners ───────────────────────────────────────────────────────────── */
+.spinner {
+ display: inline-block;
+ width: 20px;
+ height: 20px;
+ border: 2px solid var(--surface2);
+ border-top-color: var(--accent);
+ border-radius: 50%;
+ animation: spin 0.8s linear infinite;
+}
+
+@keyframes spin {
+ to { transform: rotate(360deg); }
+}
+
+/* ── Toasts ────────────────────────────────────────────────────────────── */
+#toast-container {
+ position: fixed;
+ bottom: 2rem;
+ right: 2rem;
+ z-index: 1000;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.toast {
+ background-color: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ padding: 1rem 1.5rem;
+ min-width: 280px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
+ animation: slideIn 0.3s ease;
+ display: flex;
+ gap: 0.75rem;
+ align-items: flex-start;
+}
+
+@keyframes slideIn {
+ from {
+ opacity: 0;
+ transform: translateX(400px);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes slideOut {
+ to {
+ opacity: 0;
+ transform: translateX(400px);
+ }
+}
+
+.toast.exiting {
+ animation: slideOut 0.3s ease forwards;
+}
+
+.toast-icon {
+ flex-shrink: 0;
+ font-size: 1.25rem;
+ line-height: 1;
+}
+
+.toast-message {
+ flex: 1;
+ font-size: 0.9rem;
+}
+
+.toast-success {
+ border-color: var(--success);
+}
+
+.toast-success .toast-icon {
+ color: var(--success);
+}
+
+.toast-error {
+ border-color: var(--danger);
+}
+
+.toast-error .toast-icon {
+ color: var(--danger);
+}
+
+/* ── Utility Classes ────────────────────────────────────────────────────── */
+.hidden {
+ display: none !important;
+}
+
+.empty-state {
+ text-align: center;
+ padding: 3rem 2rem;
+ color: var(--text-muted);
+ font-size: 1rem;
+}
+
+.info-banner {
+ background-color: rgba(59, 130, 246, 0.1);
+ border: 1px solid rgba(59, 130, 246, 0.3);
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+ color: var(--info);
+ font-size: 0.9rem;
+}
+
+.warning-banner {
+ background-color: rgba(245, 158, 11, 0.1);
+ border: 1px solid rgba(245, 158, 11, 0.3);
+ border-radius: 0.5rem;
+ padding: 1rem;
+ margin-bottom: 1.5rem;
+ color: var(--warning);
+ font-size: 0.9rem;
+}
+
+/* ── Responsive ────────────────────────────────────────────────────────── */
+@media (max-width: 768px) {
+ main {
+ padding: 1rem;
+ }
+
+ header {
+ padding: 1rem;
+ flex-direction: column;
+ gap: 1rem;
+ }
+
+ .tab-nav {
+ padding: 0.5rem 1rem;
+ gap: 0.25rem;
+ }
+
+ .tab-btn {
+ padding: 0.5rem 1rem;
+ font-size: 0.8rem;
+ }
+
+ .tab-header {
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 1rem;
+ }
+
+ .form-row {
+ grid-template-columns: 1fr;
+ }
+
+ .stat-item {
+ grid-template-columns: 120px 1fr 60px;
+ }
+
+ #toast-container {
+ bottom: 1rem;
+ right: 1rem;
+ left: 1rem;
+ }
+
+ .toast {
+ min-width: unset;
+ }
+}