diff options
| -rw-r--r-- | .gitignore | 7 | ||||
| -rw-r--r-- | config.py | 16 | ||||
| -rw-r--r-- | database.py | 20 | ||||
| -rw-r--r-- | main.py | 51 | ||||
| -rw-r--r-- | models.py | 75 | ||||
| -rw-r--r-- | requirements.txt | 6 | ||||
| -rw-r--r-- | routers/__init__.py | 0 | ||||
| -rw-r--r-- | routers/grocery.py | 186 | ||||
| -rw-r--r-- | routers/meals.py | 97 | ||||
| -rw-r--r-- | routers/menus.py | 138 | ||||
| -rw-r--r-- | routers/pantry.py | 101 | ||||
| -rw-r--r-- | routers/recipes.py | 50 | ||||
| -rw-r--r-- | schemas.py | 203 | ||||
| -rw-r--r-- | services/__init__.py | 0 | ||||
| -rw-r--r-- | services/ai_service.py | 158 | ||||
| -rw-r--r-- | services/pantry_service.py | 51 | ||||
| -rw-r--r-- | static/app.js | 757 | ||||
| -rw-r--r-- | static/index.html | 127 | ||||
| -rw-r--r-- | static/style.css | 823 |
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() @@ -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 & 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; + } +} |
