From ecd8c375f711ff9a81a83cb4388907b5154d1611 Mon Sep 17 00:00:00 2001 From: sstent Date: Thu, 2 Oct 2025 08:22:40 -0700 Subject: [PATCH] fixing meal edit on teampltes page --- .gitignore | 1 + alembic/env.py | 14 +- ...b9e48_add_is_deleted_to_trackedmealfood.py | 30 + app/api/routes/tracker.py | 626 ++++++++---------- app/database.py | 3 +- bug_repro_test.md | 120 ---- detailed.patch | 21 - docker-compose.yml | 4 +- fix_detailed.md | 158 ----- fix_tracer.py | 125 ---- ...ner_2025-10-01_18-47-31.db:Zone.Identifier | 0 plans.patch | 160 ----- standardization.md | 320 --------- templates/error.html | 38 ++ templates/tracker.html | 58 +- test_edit_meal.sh | 102 +++ tests/test_edit_tracked_meal.py | 199 ++++-- tracker.patch | 115 ---- 18 files changed, 636 insertions(+), 1458 deletions(-) create mode 100644 alembic/versions/2498205b9e48_add_is_deleted_to_trackedmealfood.py delete mode 100644 bug_repro_test.md delete mode 100644 detailed.patch delete mode 100644 fix_detailed.md delete mode 100644 fix_tracer.py delete mode 100644 meal_planner_2025-10-01_18-47-31.db:Zone.Identifier delete mode 100644 plans.patch delete mode 100644 standardization.md create mode 100644 templates/error.html create mode 100755 test_edit_meal.sh delete mode 100644 tracker.patch diff --git a/.gitignore b/.gitignore index 47b3630..ae311a3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ .kilocode/ *.pyc __pycache__/ +data/ \ No newline at end of file diff --git a/alembic/env.py b/alembic/env.py index 151d080..0a5426d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,11 +1,17 @@ from logging.config import fileConfig import logging import os +import sys from sqlalchemy import engine_from_config from sqlalchemy import pool from alembic import context +# Add the project root to the Python path +sys.path.insert(0, os.path.realpath(os.path.join(os.path.dirname(__file__), '..'))) + +from app.database import Base + # this is the Alembic Config object, which provides # access to the values within the .ini file in use. config = context.config @@ -17,9 +23,7 @@ if config.config_file_name is not None: # add your model's MetaData object here # for 'autogenerate' support -# We create an empty metadata object since we're not using autogenerate -# and we have explicit migration files -target_metadata = None +target_metadata = Base.metadata # other values from the config, defined by the needs of env.py, # can be acquired: @@ -43,7 +47,7 @@ def run_migrations_offline() -> None: url = os.getenv('DATABASE_URL', config.get_main_option("sqlalchemy.url")) context.configure( url=url, - target_metadata=None, + target_metadata=target_metadata, literal_binds=True, dialect_opts={"paramstyle": "named"}, ) @@ -72,7 +76,7 @@ def run_migrations_online() -> None: with connectable.connect() as connection: logging.info("DEBUG: Database connection established for alembic") context.configure( - connection=connection, target_metadata=None + connection=connection, target_metadata=target_metadata ) logging.info("DEBUG: Alembic context configured") diff --git a/alembic/versions/2498205b9e48_add_is_deleted_to_trackedmealfood.py b/alembic/versions/2498205b9e48_add_is_deleted_to_trackedmealfood.py new file mode 100644 index 0000000..3cc388a --- /dev/null +++ b/alembic/versions/2498205b9e48_add_is_deleted_to_trackedmealfood.py @@ -0,0 +1,30 @@ +"""Add is_deleted to TrackedMealFood + +Revision ID: 2498205b9e48 +Revises: d0c142fbf0b0 +Create Date: 2025-10-02 13:22:15.674346 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '2498205b9e48' +down_revision: Union[str, None] = 'd0c142fbf0b0' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('tracked_meal_foods', sa.Column('is_deleted', sa.Boolean(), nullable=True, server_default='0')) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('tracked_meal_foods', 'is_deleted') + # ### end Alembic commands ### diff --git a/app/api/routes/tracker.py b/app/api/routes/tracker.py index 350e0ca..8b0d0de 100644 --- a/app/api/routes/tracker.py +++ b/app/api/routes/tracker.py @@ -2,7 +2,6 @@ from fastapi import APIRouter, Depends, HTTPException, Request, Form, Body from fastapi.responses import HTMLResponse, RedirectResponse from sqlalchemy.orm import Session, joinedload from datetime import date, datetime, timedelta -import logging from typing import List, Optional, Union # Import from the database module @@ -14,68 +13,77 @@ router = APIRouter() # Tracker tab - Main page @router.get("/tracker", response_class=HTMLResponse) async def tracker_page(request: Request, person: str = "Sarah", date: str = None, db: Session = Depends(get_db)): - logging.info(f"DEBUG: Tracker page requested with person={person}, date={date}") - - from datetime import datetime, timedelta - - # If no date provided, use today - if not date: - current_date = datetime.now().date() - else: - current_date = datetime.fromisoformat(date).date() - - # Calculate previous and next dates - prev_date = (current_date - timedelta(days=1)).isoformat() - next_date = (current_date + timedelta(days=1)).isoformat() - - # Get or create tracked day - tracked_day = db.query(TrackedDay).filter( - TrackedDay.person == person, - TrackedDay.date == current_date - ).first() - - if not tracked_day: - # Create new tracked day - tracked_day = TrackedDay(person=person, date=current_date, is_modified=False) - db.add(tracked_day) - db.commit() - db.refresh(tracked_day) - logging.info(f"DEBUG: Created new tracked day for {person} on {current_date}") - - # Get tracked meals for this day with eager loading of meal foods - tracked_meals = db.query(TrackedMeal).options( - joinedload(TrackedMeal.meal).joinedload(Meal.meal_foods).joinedload(MealFood.food) - ).filter( - TrackedMeal.tracked_day_id == tracked_day.id - ).all() - - # Get all meals for dropdown - meals = db.query(Meal).all() - - # Get all templates for template dropdown - templates_list = db.query(Template).all() + try: + from datetime import datetime, timedelta + + # If no date provided, use today + if not date: + current_date = datetime.now().date() + else: + current_date = datetime.fromisoformat(date).date() + + # Calculate previous and next dates + prev_date = (current_date - timedelta(days=1)).isoformat() + next_date = (current_date + timedelta(days=1)).isoformat() + + # Get or create tracked day + tracked_day = db.query(TrackedDay).filter( + TrackedDay.person == person, + TrackedDay.date == current_date + ).first() + + if not tracked_day: + # Create new tracked day + tracked_day = TrackedDay(person=person, date=current_date, is_modified=False) + db.add(tracked_day) + db.commit() + db.refresh(tracked_day) + + # Get tracked meals for this day with eager loading of meal foods + tracked_meals = db.query(TrackedMeal).options( + joinedload(TrackedMeal.meal) + .joinedload(Meal.meal_foods) + .joinedload(MealFood.food), + joinedload(TrackedMeal.tracked_foods) + .joinedload(TrackedMealFood.food) + ).filter( + TrackedMeal.tracked_day_id == tracked_day.id + ).all() + + # Get all meals for dropdown + meals = db.query(Meal).all() + + # Get all templates for template dropdown + templates_list = db.query(Template).all() - # Get all foods for dropdown - foods = db.query(Food).all() + # Get all foods for dropdown + foods = db.query(Food).all() + + # Calculate day totals + day_totals = calculate_day_nutrition_tracked(tracked_meals, db) + + return templates.TemplateResponse("tracker.html", { + "request": request, + "person": person, + "current_date": current_date, + "prev_date": prev_date, + "next_date": next_date, + "tracked_meals": tracked_meals, + "is_modified": tracked_day.is_modified, + "day_totals": day_totals, + "meals": meals, + "templates": templates_list, + "foods": foods + }) - # Calculate day totals - day_totals = calculate_day_nutrition_tracked(tracked_meals, db) - - logging.info(f"DEBUG: Rendering tracker page with {len(tracked_meals)} tracked meals") - - return templates.TemplateResponse("tracker.html", { - "request": request, - "person": person, - "current_date": current_date, - "prev_date": prev_date, - "next_date": next_date, - "tracked_meals": tracked_meals, - "is_modified": tracked_day.is_modified, - "day_totals": day_totals, - "meals": meals, - "templates": templates_list, - "foods": foods - }) + except Exception as e: + # Return a detailed error page instead of generic Internal Server Error + return templates.TemplateResponse("error.html", { + "request": request, + "error_title": "Error Loading Tracker", + "error_message": f"An error occurred while loading the tracker page: {str(e)}", + "error_details": f"Person: {person}, Date: {date}" + }, status_code=500) # Tracker API Routes @router.post("/tracker/add_meal") @@ -88,7 +96,6 @@ async def tracker_add_meal(request: Request, db: Session = Depends(get_db)): meal_id = form_data.get("meal_id") meal_time = form_data.get("meal_time") - logging.info(f"DEBUG: Adding meal to tracker - person={person}, date={date_str}, meal_id={meal_id}, meal_time={meal_time}") # Parse date from datetime import datetime @@ -119,19 +126,16 @@ async def tracker_add_meal(request: Request, db: Session = Depends(get_db)): db.commit() - logging.info(f"DEBUG: Successfully added meal to tracker") return {"status": "success"} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error adding meal to tracker: {e}") return {"status": "error", "message": str(e)} @router.delete("/tracker/remove_meal/{tracked_meal_id}") async def tracker_remove_meal(tracked_meal_id: int, db: Session = Depends(get_db)): """Remove a meal from the tracker""" try: - logging.info(f"DEBUG: Removing tracked meal with ID: {tracked_meal_id}") tracked_meal = db.query(TrackedMeal).filter(TrackedMeal.id == tracked_meal_id).first() if not tracked_meal: @@ -144,12 +148,10 @@ async def tracker_remove_meal(tracked_meal_id: int, db: Session = Depends(get_db db.delete(tracked_meal) db.commit() - logging.info(f"DEBUG: Successfully removed tracked meal") return {"status": "success"} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error removing tracked meal: {e}") return {"status": "error", "message": str(e)} @router.post("/tracker/save_template") @@ -164,7 +166,6 @@ async def tracker_save_template(request: Request, db: Session = Depends(get_db)) if not all([person, date_str, template_name]): raise HTTPException(status_code=400, detail="Missing required form data.") - logging.info(f"debug: saving template - name={template_name}, person={person}, date={date_str}") # 1. Check if template name already exists existing_template = db.query(Template).filter(Template.name == template_name).first() @@ -202,12 +203,10 @@ async def tracker_save_template(request: Request, db: Session = Depends(get_db)) db.add(template_meal_entry) db.commit() - logging.info(f"debug: successfully saved template '{template_name}' with {len(tracked_meals)} meals.") return {"status": "success", "message": "Template saved successfully."} except Exception as e: db.rollback() - logging.error(f"debug: error saving template: {e}") return {"status": "error", "message": str(e)} @router.post("/tracker/apply_template") @@ -219,7 +218,6 @@ async def tracker_apply_template(request: Request, db: Session = Depends(get_db) date_str = form_data.get("date") template_id = form_data.get("template_id") - logging.info(f"DEBUG: Applying template - template_id={template_id}, person={person}, date={date_str}") # Parse date from datetime import datetime @@ -267,12 +265,10 @@ async def tracker_apply_template(request: Request, db: Session = Depends(get_db) db.commit() - logging.info(f"DEBUG: Successfully applied template with {len(template_meals)} meals") return {"status": "success"} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error applying template: {e}") return {"status": "error", "message": str(e)} @router.post("/tracker/update_tracked_food") @@ -283,7 +279,6 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess grams = float(data.get("grams", 1.0)) is_custom = data.get("is_custom", False) - logging.info(f"DEBUG: Updating tracked food {tracked_food_id} grams to {grams}") if is_custom: tracked_food = db.query(TrackedMealFood).filter(TrackedMealFood.id == tracked_food_id).first() @@ -319,12 +314,10 @@ async def update_tracked_food(request: Request, data: dict = Body(...), db: Sess db.commit() - logging.info(f"DEBUG: Successfully updated tracked food grams") return {"status": "success"} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error updating tracked food: {e}") return {"status": "error", "message": str(e)} @router.post("/tracker/reset_to_plan") @@ -335,7 +328,6 @@ async def tracker_reset_to_plan(request: Request, db: Session = Depends(get_db)) person = form_data.get("person") date_str = form_data.get("date") - logging.info(f"DEBUG: Resetting to plan - person={person}, date={date_str}") # Parse date from datetime import datetime @@ -360,83 +352,84 @@ async def tracker_reset_to_plan(request: Request, db: Session = Depends(get_db)) db.commit() - logging.info(f"DEBUG: Successfully reset to plan") return {"status": "success"} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error resetting to plan: {e}") return {"status": "error", "message": str(e)} @router.get("/tracker/get_tracked_meal_foods/{tracked_meal_id}") async def get_tracked_meal_foods(tracked_meal_id: int, db: Session = Depends(get_db)): """Get foods associated with a tracked meal""" - logging.info(f"DEBUG: get_tracked_meal_foods called for tracked_meal_id: {tracked_meal_id}") try: tracked_meal = db.query(TrackedMeal).filter(TrackedMeal.id == tracked_meal_id).first() - logging.info(f"DEBUG: Tracked meal found: {tracked_meal.id if tracked_meal else 'None'}") if not tracked_meal: raise HTTPException(status_code=404, detail="Tracked meal not found") # Load the associated Meal and its foods meal = db.query(Meal).options(joinedload(Meal.meal_foods).joinedload(MealFood.food)).filter(Meal.id == tracked_meal.meal_id).first() - logging.info(f"DEBUG: Associated meal found: {meal.id if meal else 'None'}") if not meal: raise HTTPException(status_code=404, detail="Associated meal not found") # Load custom tracked foods for this tracked meal tracked_foods = db.query(TrackedMealFood).options(joinedload(TrackedMealFood.food)).filter(TrackedMealFood.tracked_meal_id == tracked_meal_id).all() - logging.info(f"DEBUG: Found {len(tracked_foods)} custom tracked foods.") - # Combine foods from the base meal and custom tracked foods, handling overrides + # New override-based logic meal_foods_data = [] - - # Keep track of food_ids that have been overridden by TrackedMealFood entries - # These should not be added from the base meal definition - overridden_food_ids = {tf.food_id for tf in tracked_foods} - logging.info(f"DEBUG: Overridden food IDs: {overridden_food_ids}") + base_foods = {mf.food_id: mf for mf in meal.meal_foods} + overrides = {tf.food_id: tf for tf in tracked_foods} - for meal_food in meal.meal_foods: - # Only add meal_food if it hasn't been overridden by a TrackedMealFood - if meal_food.food_id not in overridden_food_ids: + # 1. Handle base meal foods, applying overrides where they exist + for food_id, base_meal_food in base_foods.items(): + if food_id in overrides: + override_food = overrides[food_id] + if not override_food.is_deleted: + # This food is overridden, use the override's data + meal_foods_data.append({ + "id": override_food.id, + "food_id": override_food.food.id, + "food_name": override_food.food.name, + "quantity": override_food.quantity, + "serving_unit": override_food.food.serving_unit, + "serving_size": override_food.food.serving_size, + "is_custom": True # It's an override, so treat as custom + }) + else: + # No override exists, use the base meal food data meal_foods_data.append({ - "id": meal_food.id, - "food_id": meal_food.food.id, - "food_name": meal_food.food.name, - "quantity": meal_food.quantity, - "serving_unit": meal_food.food.serving_unit, - "serving_size": meal_food.food.serving_size, + "id": base_meal_food.id, + "food_id": base_meal_food.food.id, + "food_name": base_meal_food.food.name, + "quantity": base_meal_food.quantity, + "serving_unit": base_meal_food.food.serving_unit, + "serving_size": base_meal_food.food.serving_size, "is_custom": False }) - - logging.info(f"DEBUG: Added {len(meal_foods_data)} meal foods (excluding overridden).") - for tracked_food in tracked_foods: - meal_foods_data.append({ - "id": tracked_food.id, - "food_id": tracked_food.food.id, - "food_name": tracked_food.food.name, - "quantity": tracked_food.quantity, - "serving_unit": tracked_food.food.serving_unit, - "serving_size": tracked_food.food.serving_size, - "is_custom": True - }) - logging.info(f"DEBUG: Added {len(tracked_foods)} custom tracked foods.") - logging.info(f"DEBUG: Total meal foods data items: {len(meal_foods_data)}") + # 2. Add new foods that are not in the base meal + for food_id, tracked_food in overrides.items(): + if food_id not in base_foods and not tracked_food.is_deleted: + meal_foods_data.append({ + "id": tracked_food.id, + "food_id": tracked_food.food.id, + "food_name": tracked_food.food.name, + "quantity": tracked_food.quantity, + "serving_unit": tracked_food.food.serving_unit, + "serving_size": tracked_food.food.serving_size, + "is_custom": True + }) return {"status": "success", "meal_foods": meal_foods_data} except HTTPException as he: - logging.error(f"DEBUG: HTTP Error getting tracked meal foods: {he.detail}") return {"status": "error", "message": he.detail} except Exception as e: - logging.error(f"DEBUG: Error getting tracked meal foods: {e}") return {"status": "error", "message": str(e)} @router.post("/tracker/add_food_to_tracked_meal") async def add_food_to_tracked_meal(data: dict = Body(...), db: Session = Depends(get_db)): - """Add a food to an existing tracked meal""" + """Add a food to an existing tracked meal by creating a TrackedMealFood entry.""" try: tracked_meal_id = data.get("tracked_meal_id") food_id = data.get("food_id") @@ -450,13 +443,14 @@ async def add_food_to_tracked_meal(data: dict = Body(...), db: Session = Depends if not food: raise HTTPException(status_code=404, detail="Food not found") - # Create a new MealFood entry for the tracked meal's associated meal - meal_food = MealFood( - meal_id=tracked_meal.meal_id, + # Create a new TrackedMealFood entry to associate the food with the tracked meal + tracked_meal_food = TrackedMealFood( + tracked_meal_id=tracked_meal.id, food_id=food_id, - quantity=grams + quantity=grams, + is_override=False # This is a new addition, not an override ) - db.add(meal_food) + db.add(tracked_meal_food) # Mark the tracked day as modified tracked_meal.tracked_day.is_modified = True @@ -466,93 +460,78 @@ async def add_food_to_tracked_meal(data: dict = Body(...), db: Session = Depends except HTTPException as he: db.rollback() - logging.error(f"DEBUG: HTTP Error adding food to tracked meal: {he.detail}") return {"status": "error", "message": he.detail} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error adding food to tracked meal: {e}") return {"status": "error", "message": str(e)} @router.post("/tracker/update_tracked_meal_foods") async def update_tracked_meal_foods(data: dict = Body(...), db: Session = Depends(get_db)): - """Update quantities of multiple foods in a tracked meal""" - logging.info(f"DEBUG: update_tracked_meal_foods called for tracked_meal_id: {data.get('tracked_meal_id')}") + """Update, add, or remove foods from a tracked meal using an override system.""" try: tracked_meal_id = data.get("tracked_meal_id") foods_data = data.get("foods", []) - logging.info(f"DEBUG: Foods data received: {foods_data}") + removed_food_ids = data.get("removed_food_ids", []) tracked_meal = db.query(TrackedMeal).filter(TrackedMeal.id == tracked_meal_id).first() - logging.info(f"DEBUG: Tracked meal found: {tracked_meal.id if tracked_meal else 'None'}") if not tracked_meal: raise HTTPException(status_code=404, detail="Tracked meal not found") + # Process removals: mark existing foods as deleted + for food_id_to_remove in removed_food_ids: + # Check if an override already exists + override = db.query(TrackedMealFood).filter( + TrackedMealFood.tracked_meal_id == tracked_meal_id, + TrackedMealFood.food_id == food_id_to_remove + ).first() + if override: + override.is_deleted = True + else: + # If no override exists, create one to mark the food as deleted + new_override = TrackedMealFood( + tracked_meal_id=tracked_meal_id, + food_id=food_id_to_remove, + quantity=0, # Quantity is irrelevant for a deleted item + is_override=True, + is_deleted=True + ) + db.add(new_override) + + # Process updates and additions for food_data in foods_data: food_id = food_data.get("food_id") grams = float(food_data.get("grams", 1.0)) - is_custom = food_data.get("is_custom", False) - item_id = food_data.get("id") # This could be MealFood.id or TrackedMealFood.id - logging.info(f"DEBUG: Processing food_id: {food_id}, grams: {grams}, is_custom: {is_custom}, item_id: {item_id}") + print(f" Processing food_id {food_id} with grams {grams}") - if is_custom: - tracked_food = db.query(TrackedMealFood).filter(TrackedMealFood.id == item_id).first() - if tracked_food: - tracked_food.quantity = grams - logging.info(f"DEBUG: Updated existing custom tracked food {item_id} to grams {grams}") - else: - # If it's a new custom food being added - new_tracked_food = TrackedMealFood( - tracked_meal_id=tracked_meal.id, - food_id=food_id, - quantity=grams - ) - db.add(new_tracked_food) - logging.info(f"DEBUG: Added new custom tracked food for food_id {food_id} with grams {grams}") + # Check if an override entry already exists for this food + existing_override = db.query(TrackedMealFood).filter( + TrackedMealFood.tracked_meal_id == tracked_meal_id, + TrackedMealFood.food_id == food_id + ).first() + + if existing_override: + # If an override exists, update its quantity and ensure it's not marked as deleted + print(f" Found existing override for food_id {food_id}. Updating quantity to {grams}.") + existing_override.quantity = grams + existing_override.is_deleted = False else: - # This is a food from the original meal definition - # We need to check if it's already a TrackedMealFood (meaning it was overridden) - # Or if it's still a MealFood - existing_tracked_food = db.query(TrackedMealFood).filter( - TrackedMealFood.tracked_meal_id == tracked_meal.id, - TrackedMealFood.food_id == food_id + # If no override exists, it's either a modification of a base food or a new addition + print(f" No existing override for food_id {food_id}. Creating new entry.") + base_meal_food = db.query(MealFood).filter( + MealFood.meal_id == tracked_meal.meal_id, + MealFood.food_id == food_id ).first() - logging.info(f"DEBUG: Checking for existing TrackedMealFood for food_id {food_id}: {existing_tracked_food.id if existing_tracked_food else 'None'}") + + is_override = base_meal_food is not None + print(f" Is this an override of a base meal food? {is_override}") + new_entry = TrackedMealFood( + tracked_meal_id=tracked_meal_id, + food_id=food_id, + quantity=grams, + is_override=is_override + ) + db.add(new_entry) - if existing_tracked_food: - existing_tracked_food.quantity = float(grams) - logging.info(f"DEBUG: Updated existing TrackedMealFood {existing_tracked_food.id} (override) to grams {grams}") - else: - # If it's not a TrackedMealFood, it must be a MealFood - meal_food = db.query(MealFood).filter( - MealFood.meal_id == tracked_meal.meal_id, - MealFood.food_id == food_id - ).first() - logging.info(f"DEBUG: Checking for existing MealFood for food_id {food_id}: {meal_food.id if meal_food else 'None'}") - if meal_food: - # If grams changed, convert to TrackedMealFood - if meal_food.quantity != grams: - new_tracked_food = TrackedMealFood( - tracked_meal_id=tracked_meal.id, - food_id=food_id, - quantity=float(grams), - is_override=True - ) - db.add(new_tracked_food) - db.delete(meal_food) # Remove original MealFood - logging.info(f"DEBUG: Converted MealFood {meal_food.id} to new TrackedMealFood for food_id {food_id} with grams {grams} and deleted original MealFood.") - else: - logging.info(f"DEBUG: MealFood {meal_food.id} grams unchanged, no override needed.") - else: - # This case should ideally not happen if data is consistent, - # but as a fallback, add as a new TrackedMealFood - new_tracked_food = TrackedMealFood( - tracked_meal_id=tracked_meal.id, - food_id=food_id, - quantity=float(grams) - ) - db.add(new_tracked_food) - logging.warning(f"DEBUG: Fallback: Added new TrackedMealFood for food_id {food_id} with grams {grams}. Original MealFood not found.") - # Mark the tracked day as modified tracked_meal.tracked_day.is_modified = True @@ -561,66 +540,12 @@ async def update_tracked_meal_foods(data: dict = Body(...), db: Session = Depend except HTTPException as he: db.rollback() - logging.error(f"DEBUG: HTTP Error updating tracked meal foods: {he.detail}") return {"status": "error", "message": he.detail} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error updating tracked meal foods: {e}") return {"status": "error", "message": str(e)} -@router.delete("/tracker/remove_food_from_tracked_meal/{meal_food_id}") -async def remove_food_from_tracked_meal(meal_food_id: int, db: Session = Depends(get_db)): - """Remove a food from a tracked meal""" - try: - meal_food = db.query(MealFood).filter(MealFood.id == meal_food_id).first() - if not meal_food: - raise HTTPException(status_code=404, detail="Meal food not found") - # Mark the tracked day as modified - tracked_meal = db.query(TrackedMeal).filter(TrackedMeal.meal_id == meal_food.meal_id).first() - if tracked_meal: - tracked_meal.tracked_day.is_modified = True - - db.delete(meal_food) - db.commit() - - return {"status": "success"} - - except HTTPException as he: - db.rollback() - logging.error(f"DEBUG: HTTP Error removing food from tracked meal: {he.detail}") - return {"status": "error", "message": he.detail} - except Exception as e: - db.rollback() - logging.error(f"DEBUG: Error removing food from tracked meal: {e}") - return {"status": "error", "message": str(e)} - -@router.delete("/tracker/remove_custom_food_from_tracked_meal/{tracked_meal_food_id}") -async def remove_custom_food_from_tracked_meal(tracked_meal_food_id: int, db: Session = Depends(get_db)): - """Remove a custom food from a tracked meal""" - try: - tracked_meal_food = db.query(TrackedMealFood).filter(TrackedMealFood.id == tracked_meal_food_id).first() - if not tracked_meal_food: - raise HTTPException(status_code=404, detail="Tracked meal food not found") - - # Mark the tracked day as modified - tracked_meal = tracked_meal_food.tracked_meal - if tracked_meal: - tracked_meal.tracked_day.is_modified = True - - db.delete(tracked_meal_food) - db.commit() - - return {"status": "success"} - - except HTTPException as he: - db.rollback() - logging.error(f"DEBUG: HTTP Error removing custom food from tracked meal: {he.detail}") - return {"status": "error", "message": he.detail} - except Exception as e: - db.rollback() - logging.error(f"DEBUG: Error removing custom food from tracked meal: {e}") - return {"status": "error", "message": str(e)} @router.post("/tracker/save_as_new_meal") async def save_as_new_meal(data: dict = Body(...), db: Session = Depends(get_db)): @@ -673,11 +598,9 @@ async def save_as_new_meal(data: dict = Body(...), db: Session = Depends(get_db) except HTTPException as he: db.rollback() - logging.error(f"DEBUG: HTTP Error saving as new meal: {he.detail}") return {"status": "error", "message": he.detail} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error saving as new meal: {e}") return {"status": "error", "message": str(e)} @router.post("/tracker/add_food") @@ -690,8 +613,6 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db) grams = float(data.get("quantity", 1.0)) meal_time = data.get("meal_time") - logging.info(f"BUG HUNT: Received raw data: {data}") - logging.info(f"BUG HUNT: Parsed grams: {grams}") # Parse date from datetime import datetime @@ -739,16 +660,13 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db) db.commit() - logging.info(f"DEBUG: Successfully added single food to tracker") return {"status": "success"} except ValueError as ve: db.rollback() - logging.error(f"DEBUG: Error adding single food to tracker: {ve}") return {"status": "error", "message": str(ve)} except Exception as e: db.rollback() - logging.error(f"DEBUG: Error adding single food to tracker: {e}") return {"status": "error", "message": str(e)} @router.get("/detailed_tracked_day", response_class=HTMLResponse, name="detailed_tracked_day") @@ -756,115 +674,121 @@ async def detailed_tracked_day(request: Request, person: str = "Sarah", date: Op """ Displays a detailed view of a tracked day, including all meals and their food breakdowns. """ - logging.info(f"DEBUG: Detailed tracked day page requested with person={person}, date={date}") + try: + # If no date is provided, default to today's date + if not date: + current_date = date.today() + else: + try: + current_date = datetime.fromisoformat(date).date() + except ValueError: + return templates.TemplateResponse("error.html", { + "request": request, + "error_title": "Invalid Date Format", + "error_message": "The date format is invalid. Please use YYYY-MM-DD format.", + "error_details": f"Date provided: {date}" + }, status_code=400) - # If no date is provided, default to today's date - if not date: - current_date = date.today() - else: - try: - current_date = datetime.fromisoformat(date).date() - except ValueError: - logging.error(f"DEBUG: Invalid date format for date: {date}") - return templates.TemplateResponse("detailed.html", { - "request": request, "title": "Invalid Date", - "error": "Invalid date format. Please use YYYY-MM-DD.", + tracked_day = db.query(TrackedDay).filter( + TrackedDay.person == person, + TrackedDay.date == current_date + ).first() + + if not tracked_day: + return templates.TemplateResponse("detailed_tracked_day.html", { + "request": request, "title": "No Tracked Day Found", + "error": "No tracked meals found for this day.", "day_totals": {}, - "person": person - }) - - tracked_day = db.query(TrackedDay).filter( - TrackedDay.person == person, - TrackedDay.date == current_date - ).first() - - if not tracked_day: - return templates.TemplateResponse("detailed_tracked_day.html", { - "request": request, "title": "No Tracked Day Found", - "error": "No tracked meals found for this day.", - "day_totals": {}, - "person": person, - "plan_date": current_date # Pass current_date for consistent template behavior - }) - - tracked_meals = db.query(TrackedMeal).options( - joinedload(TrackedMeal.meal).joinedload(Meal.meal_foods).joinedload(MealFood.food), - joinedload(TrackedMeal.tracked_foods).joinedload(TrackedMealFood.food) - ).filter( - TrackedMeal.tracked_day_id == tracked_day.id - ).all() - - day_totals = calculate_day_nutrition_tracked(tracked_meals, db) - - meal_details = [] - for tracked_meal in tracked_meals: - meal_nutrition = calculate_meal_nutrition(tracked_meal.meal, db) # Base meal nutrition - - foods = [] - # Add foods from the base meal definition - for mf in tracked_meal.meal.meal_foods: - try: - serving_size_value = float(mf.food.serving_size) - num_servings = mf.quantity / serving_size_value if serving_size_value != 0 else 0 - except (ValueError, TypeError): - num_servings = 0 # Fallback for invalid serving_size - - foods.append({ - 'name': mf.food.name, - 'total_grams': mf.quantity, - 'num_servings': num_servings, - 'serving_size': mf.food.serving_size, - 'serving_unit': mf.food.serving_unit, - 'calories': mf.food.calories * num_servings, - 'protein': mf.food.protein * num_servings, - 'carbs': mf.food.carbs * num_servings, - 'fat': mf.food.fat * num_servings, - 'fiber': (mf.food.fiber or 0) * num_servings, - 'sugar': (mf.food.sugar or 0) * num_servings, - 'sodium': (mf.food.sodium or 0) * num_servings, - 'calcium': (mf.food.calcium or 0) * num_servings, - }) - # Add custom tracked foods (overrides or additions) - for tmf in tracked_meal.tracked_foods: - try: - serving_size_value = float(tmf.food.serving_size) - num_servings = tmf.quantity / serving_size_value if serving_size_value != 0 else 0 - except (ValueError, TypeError): - num_servings = 0 # Fallback for invalid serving_size - - foods.append({ - 'name': tmf.food.name, - 'total_grams': tmf.quantity, - 'num_servings': num_servings, - 'serving_size': tmf.food.serving_size, - 'serving_unit': tmf.food.serving_unit, - 'calories': tmf.food.calories * num_servings, - 'protein': tmf.food.protein * num_servings, - 'carbs': tmf.food.carbs * num_servings, - 'fat': tmf.food.fat * num_servings, - 'fiber': (tmf.food.fiber or 0) * num_servings, - 'sugar': (tmf.food.sugar or 0) * num_servings, - 'sodium': (tmf.food.sodium or 0) * num_servings, - 'calcium': (tmf.food.calcium or 0) * num_servings, + "person": person, + "plan_date": current_date # Pass current_date for consistent template behavior }) - meal_details.append({ - 'plan': {'meal': tracked_meal.meal, 'meal_time': tracked_meal.meal_time}, - 'nutrition': meal_nutrition, - 'foods': foods - }) + tracked_meals = db.query(TrackedMeal).options( + joinedload(TrackedMeal.meal).joinedload(Meal.meal_foods).joinedload(MealFood.food), + joinedload(TrackedMeal.tracked_foods).joinedload(TrackedMealFood.food) + ).filter( + TrackedMeal.tracked_day_id == tracked_day.id + ).all() - context = { - "request": request, - "title": f"Detailed Day for {person} on {current_date.strftime('%B %d, %Y')}", - "meal_details": meal_details, - "day_totals": day_totals, - "person": person, - "plan_date": current_date # Renamed from current_date to plan_date for consistency with detailed.html - } + day_totals = calculate_day_nutrition_tracked(tracked_meals, db) - if not meal_details: - context["message"] = "No meals tracked for this day." + meal_details = [] + for tracked_meal in tracked_meals: + meal_nutrition = calculate_meal_nutrition(tracked_meal.meal, db) # Base meal nutrition - logging.info(f"DEBUG: Rendering tracked day details with context: {context}") - return templates.TemplateResponse("detailed_tracked_day.html", context) \ No newline at end of file + foods = [] + # Add foods from the base meal definition + for mf in tracked_meal.meal.meal_foods: + try: + serving_size_value = float(mf.food.serving_size) + num_servings = mf.quantity / serving_size_value if serving_size_value != 0 else 0 + except (ValueError, TypeError): + num_servings = 0 # Fallback for invalid serving_size + + foods.append({ + 'name': mf.food.name, + 'total_grams': mf.quantity, + 'num_servings': num_servings, + 'serving_size': mf.food.serving_size, + 'serving_unit': mf.food.serving_unit, + 'calories': mf.food.calories * num_servings, + 'protein': mf.food.protein * num_servings, + 'carbs': mf.food.carbs * num_servings, + 'fat': mf.food.fat * num_servings, + 'fiber': (mf.food.fiber or 0) * num_servings, + 'sugar': (mf.food.sugar or 0) * num_servings, + 'sodium': (mf.food.sodium or 0) * num_servings, + 'calcium': (mf.food.calcium or 0) * num_servings, + }) + # Add custom tracked foods (overrides or additions) + for tmf in tracked_meal.tracked_foods: + try: + serving_size_value = float(tmf.food.serving_size) + num_servings = tmf.quantity / serving_size_value if serving_size_value != 0 else 0 + except (ValueError, TypeError): + num_servings = 0 # Fallback for invalid serving_size + + foods.append({ + 'name': tmf.food.name, + 'total_grams': tmf.quantity, + 'num_servings': num_servings, + 'serving_size': tmf.food.serving_size, + 'serving_unit': tmf.food.serving_unit, + 'calories': tmf.food.calories * num_servings, + 'protein': tmf.food.protein * num_servings, + 'carbs': tmf.food.carbs * num_servings, + 'fat': tmf.food.fat * num_servings, + 'fiber': (tmf.food.fiber or 0) * num_servings, + 'sugar': (tmf.food.sugar or 0) * num_servings, + 'sodium': (tmf.food.sodium or 0) * num_servings, + 'calcium': (tmf.food.calcium or 0) * num_servings, + }) + + meal_details.append({ + 'plan': {'meal': tracked_meal.meal, 'meal_time': tracked_meal.meal_time}, + 'nutrition': meal_nutrition, + 'foods': foods + }) + + context = { + "request": request, + "title": f"Detailed Day for {person} on {current_date.strftime('%B %d, %Y')}", + "meal_details": meal_details, + "day_totals": day_totals, + "person": person, + "plan_date": current_date # Renamed from current_date to plan_date for consistency with detailed.html + } + + if not meal_details: + context["message"] = "No meals tracked for this day." + + return templates.TemplateResponse("detailed_tracked_day.html", context) + + except Exception as e: + # Return a detailed error page instead of generic Internal Server Error + return templates.TemplateResponse("error.html", { + "request": request, + "error_title": "Error Loading Detailed View", + "error_message": f"An error occurred while loading the detailed view: {str(e)}", + "error_details": f"Person: {person}, Date: {date}" + }, status_code=500) \ No newline at end of file diff --git a/app/database.py b/app/database.py index 01f98f6..ca97096 100644 --- a/app/database.py +++ b/app/database.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, ConfigDict from typing import List, Optional from datetime import date, datetime import os +import logging # Database setup - Use SQLite for easier setup # Use environment variables if set, otherwise use defaults @@ -161,6 +162,7 @@ class TrackedMealFood(Base): food_id = Column(Integer, ForeignKey("foods.id")) quantity = Column(Float, default=1.0) # Custom quantity for this tracked instance is_override = Column(Boolean, default=False) # True if overriding original meal food, False if addition + is_deleted = Column(Boolean, default=False) # True if this food has been deleted from the meal tracked_meal = relationship("TrackedMeal", back_populates="tracked_foods") food = relationship("Food") @@ -401,7 +403,6 @@ def calculate_tracked_meal_nutrition(tracked_meal, db: Session): 'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0, 'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0 } - # Base meal nutrition base_nutrition = calculate_meal_nutrition(tracked_meal.meal, db) for key in totals: diff --git a/bug_repro_test.md b/bug_repro_test.md deleted file mode 100644 index 5773ff5..0000000 --- a/bug_repro_test.md +++ /dev/null @@ -1,120 +0,0 @@ -# Bug Reproduction Test Plan - -This document outlines the test case required to reproduce the quantity calculation bug in the "add food" modal. - -## Test File - -Create a new file at `tests/test_add_food_bug.py`. - -## Test Case - -The following pytest test should be implemented in `tests/test_add_food_bug.py`. This test will simulate the buggy behavior by creating a food with a non-standard serving size and then asserting that the stored quantity is incorrect when a specific number of "servings" is added via the API. - -```python -import pytest -from fastapi.testclient import TestClient -from sqlalchemy.orm import Session -from app.database import Food, Meal, MealFood, TrackedDay, TrackedMeal -from datetime import date - -def test_add_food_with_serving_size_multiplier(client: TestClient, db_session: Session): - """ - Simulates the bug where the quantity is a multiple of the serving size. - This test will fail if the bug exists. - """ - # 1. Create a food with a serving size of 30g - food = Food( - name="Test Cracker", - serving_size=30.0, - serving_unit="g", - calories=120, - protein=2, - carbs=25, - fat=2 - ) - db_session.add(food) - db_session.commit() - - # 2. Simulate adding the food via the API - # The user enters "2" in the quantity field, but some faulty client-side - # logic multiplies it by the serving size (2 * 30 = 60) before sending. - # We are simulating the faulty request here. - response = client.post( - "/tracker/add_food", - json={ - "person": "Sarah", - "date": date.today().isoformat(), - "food_id": food.id, - "grams": 60.0, # This is what the backend receives - "meal_time": "Snack 1" - } - ) - assert response.status_code == 200 - assert response.json()["status"] == "success" - - # 3. Verify the stored quantity - # Find the MealFood that was just created. - # The bug is that the backend stores 60g, instead of what the user *thought* they entered (2 servings, which should be stored as 60g). - # The user's report is that the quantity is a multiple. - # A correct implementation would just store the grams value. - # This test asserts the buggy behavior to prove it exists. - - # The endpoint creates a new Meal and a new TrackedMeal for the single food. - # We need to find the most recently created one. - created_meal = db_session.query(Meal).order_by(Meal.id.desc()).first() - assert created_meal is not None - assert created_meal.name == "Test Cracker" - - meal_food = db_session.query(MealFood).filter(MealFood.meal_id == created_meal.id).first() - assert meal_food is not None - - # This assertion will pass if the bug exists, because the backend is saving the wrong value. - # The goal is to make this test *fail* by fixing the backend logic. - # A correct implementation would require the frontend to always send grams. - # If the user enters "2" servings of a 30g serving size food, the frontend *should* send 60g. - # The bug description is a bit ambiguous. Let's clarify the assertion. - # The user said "the quantity value is a multiple of the serving size not in grams". - # This implies if they enter "60" in the grams field, it might be getting multiplied AGAIN. - # Let's write the test to check for THAT. - - # Re-simulating based on a clearer interpretation of the bug report. - # The user enters "60" grams. The faulty logic might be `60 * 30 = 1800`. - - # Let's create a more precise test. - - # Delete the previous test data to be safe. - db_session.delete(meal_food) - db_session.delete(created_meal) - db_session.commit() - - # Re-run with a clearer scenario - response = client.post( - "/tracker/add_food", - json={ - "person": "Sarah", - "date": date.today().isoformat(), - "food_id": food.id, - "grams": 2.0, # User wants 2 grams - "meal_time": "Snack 1" - } - ) - assert response.status_code == 200 - - created_meal = db_session.query(Meal).order_by(Meal.id.desc()).first() - meal_food = db_session.query(MealFood).filter(MealFood.meal_id == created_meal.id).first() - - # The bug is that this is NOT 2.0, but something else. - # Let's assume the bug is `quantity * serving_size`. So `2.0 * 30.0 = 60.0` - # A failing test should assert the expected *correct* value. - assert meal_food.quantity == 2.0, f"Quantity should be 2.0, but was {meal_food.quantity}" - -``` - -## Instructions for Implementation - -1. A developer in `code` mode should create the file `tests/test_add_food_bug.py`. -2. The code above should be added to this file. -3. The test should be run using the command from the TDD rules to confirm that it fails as expected, thus reproducing the bug. - -```bash -docker compose build; docker compose run --remove-orphans foodtracker pytest tests/test_add_food_bug.py \ No newline at end of file diff --git a/detailed.patch b/detailed.patch deleted file mode 100644 index 2f27f45..0000000 --- a/detailed.patch +++ /dev/null @@ -1,21 +0,0 @@ ---- templates/detailed.html -+++ templates/detailed.html -@@ -1,3 +1,7 @@ -+{# Look for the meal details section and ensure it shows food breakdown #} -+{% for meal_detail in meal_details %} -+ {# Existing meal header code... #} -+ -+ {# ADD FOOD BREAKDOWN SECTION: #} -+
-+

Food Breakdown:

-+ -+
-+{% endfor %} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index b5e3a7b..e6e1632 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: ports: - "8999:8999" environment: - - DATABASE_URL=sqlite:////app/meal_planner.db + - DATABASE_URL=sqlite:////app/data/meal_planner.db volumes: - ./alembic:/app/alembic - - ./meal_planner.db:/app/meal_planner.db \ No newline at end of file + - ./data:/app/data \ No newline at end of file diff --git a/fix_detailed.md b/fix_detailed.md deleted file mode 100644 index 0d1a824..0000000 --- a/fix_detailed.md +++ /dev/null @@ -1,158 +0,0 @@ -Fix Detailed View Food Breakdown - Implementation Plan -Problem Statement -The detailed view (/detailed route) is incorrectly calculating and displaying per-food nutrition values: - -Display Issue: Shows "34.0 × 34.0g" instead of "34.0g" in the Serving column -Calculation Issue: Multiplies nutrition by quantity directly instead of calculating proper multiplier (quantity ÷ serving_size) - -Current incorrect calculation: -python'calories': mf.food.calories * mf.quantity # Wrong: 125cal * 34g = 4250cal -Should be: -pythonmultiplier = mf.quantity / mf.food.serving_size # 34g / 34g = 1.0 -'calories': mf.food.calories * multiplier # 125cal * 1.0 = 125cal - -Files to Modify - -app/api/routes/plans.py - Fix calculation logic in detailed() function -templates/detailed.html - Update serving column display - - -Implementation Steps -Step 1: Fix Template View Calculation (plans.py) -Location: app/api/routes/plans.py, in the detailed() function around lines 190-220 -Find this section (for template meals): -pythonfor mf in tm.meal.meal_foods: - try: - serving_size_value = float(mf.food.serving_size) - num_servings = mf.quantity / serving_size_value if serving_size_value != 0 else 0 - except (ValueError, TypeError): - num_servings = 0 - - foods.append({ - 'name': mf.food.name, - 'total_grams': mf.quantity, - 'num_servings': num_servings, - 'serving_size': mf.food.serving_size, - 'serving_unit': mf.food.serving_unit, - 'calories': mf.food.calories * num_servings, # May be wrong - 'protein': mf.food.protein * num_servings, - # ... etc - }) -Replace with: -pythonfor mf in tm.meal.meal_foods: - try: - serving_size = float(mf.food.serving_size) - multiplier = mf.quantity / serving_size if serving_size > 0 else 0 - except (ValueError, TypeError): - multiplier = 0 - - foods.append({ - 'name': mf.food.name, - 'quantity': mf.quantity, # Grams used in this meal - 'serving_unit': mf.food.serving_unit, - # Calculate nutrition for the actual amount used - 'calories': (mf.food.calories or 0) * multiplier, - 'protein': (mf.food.protein or 0) * multiplier, - 'carbs': (mf.food.carbs or 0) * multiplier, - 'fat': (mf.food.fat or 0) * multiplier, - 'fiber': (mf.food.fiber or 0) * multiplier, - 'sodium': (mf.food.sodium or 0) * multiplier, - }) -Step 2: Fix Tracked Day View Calculation (plans.py) -Location: Same file, around lines 247-280 (in the tracked meals section) -Find this section: -pythonfor mf in tracked_meal.meal.meal_foods: - foods.append({ - 'name': mf.food.name, - 'quantity': mf.quantity, - 'serving_size': mf.food.serving_size, - 'serving_unit': mf.food.serving_unit, - }) -Replace with (add nutrition calculations): -pythonfor mf in tracked_meal.meal.meal_foods: - try: - serving_size = float(mf.food.serving_size) - multiplier = mf.quantity / serving_size if serving_size > 0 else 0 - except (ValueError, TypeError): - multiplier = 0 - - foods.append({ - 'name': mf.food.name, - 'quantity': mf.quantity, - 'serving_unit': mf.food.serving_unit, - 'calories': (mf.food.calories or 0) * multiplier, - 'protein': (mf.food.protein or 0) * multiplier, - 'carbs': (mf.food.carbs or 0) * multiplier, - 'fat': (mf.food.fat or 0) * multiplier, - 'fiber': (mf.food.fiber or 0) * multiplier, - 'sodium': (mf.food.sodium or 0) * multiplier, - }) -Step 3: Fix Template Display -Location: templates/detailed.html -Find the Serving column display (likely something like): -html{{ food.total_grams }} × {{ food.serving_size }}{{ food.serving_unit }} -or -html{{ food.quantity }} × {{ food.serving_size }}{{ food.serving_unit }} -Replace with: -html{{ food.quantity }}{{ food.serving_unit }} -This will show "34.0g" instead of "34.0 × 34.0g" - -Testing Checklist -After making changes, test these scenarios: -Test 1: Basic Calculation - - Food with 100g serving size, 100 calories - Add 50g to meal - Should show: "50g" and "50 calories" - -Test 2: Your Current Example - - Pea Protein: 34g serving, 125 cal/serving - Add 34g to meal - Should show: "34.0g" and "125 calories" - NOT "4250 calories" - -Test 3: Fractional Servings - - Food with 100g serving size, 200 calories - Add 150g to meal - Should show: "150g" and "300 calories" - -Test 4: Template View - - View a template from the detailed page - Verify food breakdown shows correct grams and nutrition - -Test 5: Tracked Day View - - View a tracked day from the detailed page - Verify food breakdown shows correct grams and nutrition - - -Code Quality Notes -Why Use Multiplier Pattern? -pythonmultiplier = quantity / serving_size -nutrition_value = base_nutrition * multiplier -This is consistent with: - -calculate_meal_nutrition() function -The standardization plan -Makes the math explicit and debuggable - -Error Handling -The try/except block handles: - -Non-numeric serving_size values -Division by zero -NULL values (though migration confirmed none exist) - - -Expected Results -Before: -Serving: 34.0 × 34.0g -Calories: 4250 -Protein: 952.0g -After: -Serving: 34.0g -Calories: 125 -Protein: 28.0g diff --git a/fix_tracer.py b/fix_tracer.py deleted file mode 100644 index 4aea1a4..0000000 --- a/fix_tracer.py +++ /dev/null @@ -1,125 +0,0 @@ -#!/usr/bin/env python3 -""" -Script to fix the incomplete tracker.py file -""" - -def fix_tracker_file(): - file_path = "app/api/routes/tracker.py" - - with open(file_path, 'r') as f: - content = f.read() - - # Check if file is incomplete (ends abruptly) - if content.strip().endswith('@router.post("/tracker/save_template")'): - print("File is incomplete, adding missing content...") - - missing_content = '''async def tracker_save_template(request: Request, db: Session = Depends(get_db)): - """save current day's meals as template""" - try: - form_data = await request.form() - person = form_data.get("person") - date_str = form_data.get("date") - template_name = form_data.get("template_name") - logging.info(f"debug: saving template - name={template_name}, person={person}, date={date_str}") - - # Parse date - from datetime import datetime - date = datetime.fromisoformat(date_str).date() - - # Get tracked day and meals - tracked_day = db.query(TrackedDay).filter( - TrackedDay.person == person, - TrackedDay.date == date - ).first() - if not tracked_day: - return {"status": "error", "message": "No tracked day found"} - - tracked_meals = db.query(TrackedMeal).filter( - TrackedMeal.tracked_day_id == tracked_day.id - ).all() - - if not tracked_meals: - return {"status": "error", "message": "No tracked meals found"} - - # Create new template - template = Template(name=template_name) - db.add(template) - db.flush() - - # Add meals to template - for tracked_meal in tracked_meals: - template_meal = TemplateMeal( - template_id=template.id, - meal_id=tracked_meal.meal_id, - meal_time=tracked_meal.meal_time - ) - db.add(template_meal) - - db.commit() - return {"status": "success", "message": "Template saved successfully"} - except Exception as e: - db.rollback() - logging.error(f"debug: error saving template: {e}") - return {"status": "error", "message": str(e)} - -@router.post("/tracker/apply_template") -async def tracker_apply_template(request: Request, db: Session = Depends(get_db)): - """apply template to current day""" - try: - form_data = await request.form() - person = form_data.get("person") - date_str = form_data.get("date") - template_id = form_data.get("template_id") - logging.info(f"debug: applying template - template_id={template_id}, person={person}, date={date_str}") - - # Parse date - from datetime import datetime - date = datetime.fromisoformat(date_str).date() - - # Get template meals - template_meals = db.query(TemplateMeal).filter( - TemplateMeal.template_id == template_id - ).all() - - if not template_meals: - return {"status": "error", "message": "Template has no meals"} - - # Get or create tracked day - tracked_day = db.query(TrackedDay).filter( - TrackedDay.person == person, - TrackedDay.date == date - ).first() - if not tracked_day: - tracked_day = TrackedDay(person=person, date=date, is_modified=True) - db.add(tracked_day) - db.flush() - - # Clear existing meals and add template meals - db.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).delete() - - for template_meal in template_meals: - tracked_meal = TrackedMeal( - tracked_day_id=tracked_day.id, - meal_id=template_meal.meal_id, - meal_time=template_meal.meal_time - ) - db.add(tracked_meal) - - tracked_day.is_modified = True - db.commit() - return {"status": "success", "message": "Template applied successfully"} - except Exception as e: - db.rollback() - logging.error(f"debug: error applying template: {e}") - return {"status": "error", "message": str(e)}''' - - # Append the missing content - with open(file_path, 'a') as f: - f.write('\n' + missing_content) - - print("Tracker.py file fixed successfully!") - else: - print("Tracker.py file appears to be complete") - -if __name__ == "__main__": - fix_tracker_file() \ No newline at end of file diff --git a/meal_planner_2025-10-01_18-47-31.db:Zone.Identifier b/meal_planner_2025-10-01_18-47-31.db:Zone.Identifier deleted file mode 100644 index e69de29..0000000 diff --git a/plans.patch b/plans.patch deleted file mode 100644 index f8b5747..0000000 --- a/plans.patch +++ /dev/null @@ -1,160 +0,0 @@ ---- app/api/routes/plans.py.orig -+++ app/api/routes/plans.py.fixed -@@ -1,7 +1,7 @@ - from fastapi import APIRouter, Depends, HTTPException, Request, Form, Body - from fastapi.responses import HTMLResponse, RedirectResponse --from sqlalchemy.orm import Session -+from sqlalchemy.orm import Session, joinedload - from datetime import date, datetime, timedelta - import logging - from typing import List, Optional -@@ -9,7 +9,7 @@ - # Import database module - from app.database import get_db, Food, Meal, MealFood, Plan, Template, TemplateMeal, WeeklyMenu, WeeklyMenuDay, TrackedDay, TrackedMeal, calculate_meal_nutrition, calculate_day_nutrition - from main import templates -- -+from app.database import calculate_tracked_meal_nutrition, calculate_day_nutrition_tracked - router = APIRouter() - - # Plan tab -@@ -156,6 +156,7 @@ - """render detailed view for a specific day or template""" - from datetime import datetime, date - import logging -+ from sqlalchemy.orm import joinedload - - logging.info(f"debug: detailed page requested url: {request.url.path}, query_params: {request.query_params}") - logging.info(f"debug: detailed page requested person={person}, plan_date={plan_date}, template_id={template_id}") -@@ -189,6 +190,20 @@ - for tm in template_meals: - meal_nutrition = calculate_meal_nutrition(tm.meal, db) - meal_details.append({ -+ 'plan': {'meal': tm.meal, 'meal_time': tm.meal_time}, -+ 'nutrition': meal_nutrition, -+ 'foods': [] # Template view should show individual foods -+ }) -+ # ADD FOOD BREAKDOWN FOR TEMPLATES -+ foods = [] -+ for mf in tm.meal.meal_foods: -+ foods.append({ -+ 'name': mf.food.name, -+ 'quantity': mf.quantity, -+ 'serving_size': mf.food.serving_size, -+ 'serving_unit': mf.food.serving_unit, -+ }) -+ meal_details[-1]['foods'] = foods -+ # Accumulate nutrition totals -+ for key in template_nutrition: -+ if key in meal_nutrition: -+ template_nutrition[key] += meal_nutrition[key] -@@ -232,42 +247,64 @@ - plan_date_obj = datetime.fromisoformat(plan_date).date() - except ValueError: - logging.error(f"debug: invalid date format plan_date: {plan_date}") -- return templates.TemplateResponse("detailed.html", { -+ return templates.TemplateResponse(request, "detailed.html", { - "request": request, - "title": "Invalid date", - "error": "Invalid date format. Please use YYYY-MM-DD.", - "day_totals": {}, - "templates": templates_list, -- "person": person -+ "person": person, -+ "is_tracked_view": True - }) - -- logging.info(f"debug: loading plan for {person} on {plan_date_obj}") -- plans = db.query(Plan).filter(Plan.person == person, Plan.date == plan_date_obj).all() -- logging.info(f"debug: found {len(plans)} plans for {person} on {plan_date_obj}") -- -- day_totals = calculate_day_nutrition(plans, db) -+ logging.info(f"debug: loading TRACKED meals for {person} on {plan_date_obj}") -+ -+ # Get tracked day and meals instead of planned meals -+ tracked_day = db.query(TrackedDay).filter( -+ TrackedDay.person == person, -+ TrackedDay.date == plan_date_obj -+ ).first() -+ - meal_details = [] -- for plan in plans: -- meal_nutrition = calculate_meal_nutrition(plan.meal, db) -- foods = [] -- for mf in plan.meal.meal_foods: -- foods.append({ -- 'name': mf.food.name, -- 'quantity': mf.quantity, -- 'serving_size': mf.food.serving_size, -- 'serving_unit': mf.food.serving_unit, -- 'calories': mf.food.calories * mf.quantity, -- 'protein': mf.food.protein * mf.quantity, -- 'carbs': mf.food.carbs * mf.quantity, -- 'fat': mf.food.fat * mf.quantity, -- 'fiber': (mf.food.fiber or 0) * mf.quantity, -- 'sodium': (mf.food.sodium or 0) * mf.quantity, -- }) -- meal_details.append({ -- 'plan': plan, -- 'nutrition': meal_nutrition, -- 'foods': foods -- }) -+ day_totals = {'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0, 'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0} -+ -+ if tracked_day: -+ tracked_meals = db.query(TrackedMeal).filter( -+ TrackedMeal.tracked_day_id == tracked_day.id -+ ).options(joinedload(TrackedMeal.meal).joinedload(Meal.meal_foods).joinedload(MealFood.food)).all() -+ -+ logging.info(f"debug: found {len(tracked_meals)} tracked meals for {person} on {plan_date_obj}") -+ -+ for tracked_meal in tracked_meals: -+ meal_nutrition = calculate_tracked_meal_nutrition(tracked_meal, db) -+ foods = [] -+ -+ # Show base meal foods -+ for mf in tracked_meal.meal.meal_foods: -+ foods.append({ -+ 'name': mf.food.name, -+ 'quantity': mf.quantity, -+ 'serving_size': mf.food.serving_size, -+ 'serving_unit': mf.food.serving_unit, -+ }) -+ -+ # Show custom tracked foods (overrides/additions) -+ for tracked_food in tracked_meal.tracked_foods: -+ foods.append({ -+ 'name': f"{tracked_food.food.name} {'(override)' if tracked_food.is_override else '(addition)'}", -+ 'quantity': tracked_food.quantity, -+ 'serving_size': tracked_food.food.serving_size, -+ 'serving_unit': tracked_food.food.serving_unit, -+ }) -+ -+ meal_details.append({ -+ 'plan': tracked_meal, # Use tracked_meal instead of plan -+ 'nutrition': meal_nutrition, -+ 'foods': foods -+ }) -+ -+ # Accumulate day totals -+ for key in day_totals: -+ if key in meal_nutrition: -+ day_totals[key] += meal_nutrition[key] - - context = { - "request": request, -@@ -276,10 +313,11 @@ - "day_totals": day_totals, - "person": person, - "plan_date": plan_date_obj, -- "templates": templates_list -+ "templates": templates_list, -+ "is_tracked_view": True # Add flag to indicate this is tracked view - } -- -- if not meal_details: -- context["message"] = "No meals planned for this day." -+ -+ if not meal_details and tracked_day: -+ context["message"] = "No meals tracked for this day." - - logging.info(f"debug: rendering plan details context: {context}") \ No newline at end of file diff --git a/standardization.md b/standardization.md deleted file mode 100644 index c27b01a..0000000 --- a/standardization.md +++ /dev/null @@ -1,320 +0,0 @@ -Food Planner Quantity Standardization Plan -Problem Statement -The application has inconsistent handling of food quantities throughout the codebase: - -Current Issue: MealFood.quantity is being used sometimes as a multiplier of serving_size and sometimes as grams directly -Impact: Confusing calculations in nutrition functions and unclear user interface expectations -Goal: Standardize so MealFood.quantity always represents grams of the food item - - -Core Data Model Definition -Standard to Adopt -Food.serving_size = base serving size in grams (e.g., 100) -Food.[nutrients] = nutritional values per serving_size grams -MealFood.quantity = actual grams to use (e.g., 150g) -TrackedMealFood.quantity = actual grams to use (e.g., 200g) - -Calculation: multiplier = quantity / serving_size - -Implementation Plan -Phase 1: Audit & Document (Non-Breaking) -Task 1.1: Add documentation header to app/database.py -python""" -QUANTITY CONVENTION: -All quantity fields in this application represent GRAMS. - -- Food.serving_size: base serving size in grams (e.g., 100.0) -- Food nutrition values: per serving_size grams -- MealFood.quantity: grams of this food in the meal (e.g., 150.0) -- TrackedMealFood.quantity: grams of this food as tracked (e.g., 200.0) - -To calculate nutrition: multiplier = quantity / serving_size -""" -Task 1.2: Audit all locations where quantity is read/written - - app/database.py - calculation functions - app/api/routes/meals.py - meal food operations - app/api/routes/tracker.py - tracked meal operations - app/api/routes/plans.py - detailed view - Templates using quantity values - - -Phase 2: Fix Core Calculation Functions -Task 2.1: Fix calculate_meal_nutrition() in app/database.py -Current behavior: Assumes quantity is already a multiplier -New behavior: Calculate multiplier from grams -pythondef calculate_meal_nutrition(meal, db: Session): - """ - Calculate total nutrition for a meal. - MealFood.quantity is in GRAMS. - """ - totals = { - 'calories': 0, 'protein': 0, 'carbs': 0, 'fat': 0, - 'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0 - } - - for meal_food in meal.meal_foods: - food = meal_food.food - grams = meal_food.quantity - - # Convert grams to multiplier based on serving size - try: - serving_size = float(food.serving_size) - multiplier = grams / serving_size if serving_size > 0 else 0 - except (ValueError, TypeError): - multiplier = 0 - - totals['calories'] += food.calories * multiplier - totals['protein'] += food.protein * multiplier - totals['carbs'] += food.carbs * multiplier - totals['fat'] += food.fat * multiplier - totals['fiber'] += (food.fiber or 0) * multiplier - totals['sugar'] += (food.sugar or 0) * multiplier - totals['sodium'] += (food.sodium or 0) * multiplier - totals['calcium'] += (food.calcium or 0) * multiplier - - # Calculate percentages (unchanged) - total_cals = totals['calories'] - if total_cals > 0: - totals['protein_pct'] = round((totals['protein'] * 4 / total_cals) * 100, 1) - totals['carbs_pct'] = round((totals['carbs'] * 4 / total_cals) * 100, 1) - totals['fat_pct'] = round((totals['fat'] * 9 / total_cals) * 100, 1) - totals['net_carbs'] = totals['carbs'] - totals['fiber'] - else: - totals['protein_pct'] = 0 - totals['carbs_pct'] = 0 - totals['fat_pct'] = 0 - totals['net_carbs'] = 0 - - return totals -Task 2.2: Fix calculate_tracked_meal_nutrition() in app/database.py -Apply the same pattern to handle TrackedMealFood.quantity as grams. -Task 2.3: Remove or fix convert_grams_to_quantity() function -This function appears to be unused but creates confusion. Either: - -Remove it entirely, OR -Rename to calculate_multiplier_from_grams() and update documentation - - -Phase 3: Fix API Routes -Task 3.1: Fix app/api/routes/meals.py -Location: POST /meals/{meal_id}/add_food -python@router.post("/meals/{meal_id}/add_food") -async def add_food_to_meal( - meal_id: int, - food_id: int = Form(...), - grams: float = Form(...), # Changed from 'quantity' to be explicit - db: Session = Depends(get_db) -): - try: - # Store grams directly - no conversion needed - meal_food = MealFood( - meal_id=meal_id, - food_id=food_id, - quantity=grams # This is grams - ) - db.add(meal_food) - db.commit() - return {"status": "success"} - except Exception as e: - db.rollback() - return {"status": "error", "message": str(e)} -Location: POST /meals/update_food_quantity -python@router.post("/meals/update_food_quantity") -async def update_meal_food_quantity( - meal_food_id: int = Form(...), - grams: float = Form(...), # Changed from 'quantity' - db: Session = Depends(get_db) -): - try: - meal_food = db.query(MealFood).filter(MealFood.id == meal_food_id).first() - if not meal_food: - return {"status": "error", "message": "meal food not found"} - - meal_food.quantity = grams # Store grams directly - db.commit() - return {"status": "success"} - except Exception as e: - db.rollback() - return {"status": "error", "message": str(e)} -Task 3.2: Fix app/api/routes/tracker.py -Review all tracked meal operations to ensure they handle grams correctly. -Task 3.3: Fix app/api/routes/plans.py detailed view -The detailed view calculates nutrition per food item. Update to show grams clearly: -pythonfor mf in tm.meal.meal_foods: - try: - serving_size_value = float(mf.food.serving_size) - num_servings = mf.quantity / serving_size_value if serving_size_value != 0 else 0 - except (ValueError, TypeError): - num_servings = 0 - - foods.append({ - 'name': mf.food.name, - 'total_grams': mf.quantity, # Explicitly show it's grams - 'num_servings': round(num_servings, 2), - 'serving_size': mf.food.serving_size, - 'serving_unit': mf.food.serving_unit, - # Don't recalculate nutrition here - it's done in calculate_meal_nutrition - }) - -Phase 4: Fix CSV Import Functions -Task 4.1: Fix app/api/routes/meals.py - POST /meals/upload -Currently processes ingredient pairs as (food_name, grams). Ensure it stores grams directly: -pythonfor i in range(1, len(row), 2): - if i+1 >= len(row) or not row[i].strip(): - continue - - food_name = row[i].strip() - grams = float(row[i+1].strip()) # This is grams - - # ... find food ... - - ingredients.append((food.id, grams)) # Store grams directly - -# Later when creating MealFood: -for food_id, grams in ingredients: - meal_food = MealFood( - meal_id=existing.id, - food_id=food_id, - quantity=grams # Store grams directly - ) - -Phase 5: Update Templates & UI -Task 5.1: Update templates/detailed.html -Ensure the food breakdown clearly shows grams: -html
  • - {{ food.total_grams }}g of {{ food.name }} - ({{ food.num_servings|round(2) }} servings of {{ food.serving_size }}{{ food.serving_unit }}) -
  • -Task 5.2: Update meal editing forms -Ensure all forms ask for "grams" not "quantity" to avoid confusion. - -Phase 6: Add Tests -Task 6.1: Create test file tests/test_quantity_consistency.py -pythondef test_meal_nutrition_uses_grams_correctly(db_session): - """Verify that MealFood.quantity as grams calculates nutrition correctly""" - # Create a food: 100 cal per 100g - food = Food( - name="Test Food", - serving_size=100.0, - serving_unit="g", - calories=100.0, - protein=10.0, - carbs=20.0, - fat=5.0 - ) - db_session.add(food) - db_session.commit() - - # Create a meal with 200g of this food - meal = Meal(name="Test Meal", meal_type="breakfast") - db_session.add(meal) - db_session.commit() - - meal_food = MealFood( - meal_id=meal.id, - food_id=food.id, - quantity=200.0 # 200 grams - ) - db_session.add(meal_food) - db_session.commit() - - # Calculate nutrition - nutrition = calculate_meal_nutrition(meal, db_session) - - # Should be 2x the base values (200g / 100g = 2x multiplier) - assert nutrition['calories'] == 200.0 - assert nutrition['protein'] == 20.0 - assert nutrition['carbs'] == 40.0 - assert nutrition['fat'] == 10.0 - -def test_fractional_servings(db_session): - """Test that fractional grams work correctly""" - food = Food( - name="Test Food", - serving_size=100.0, - serving_unit="g", - calories=100.0 - ) - db_session.add(food) - db_session.commit() - - meal = Meal(name="Test Meal") - db_session.add(meal) - db_session.commit() - - # Add 50g (half serving) - meal_food = MealFood( - meal_id=meal.id, - food_id=food.id, - quantity=50.0 - ) - db_session.add(meal_food) - db_session.commit() - - nutrition = calculate_meal_nutrition(meal, db_session) - assert nutrition['calories'] == 50.0 -Task 6.2: Run existing tests to verify no regressions - -Phase 7: Data Migration (if needed) -Task 7.1: Determine if existing data needs migration -Check if current database has MealFood entries where quantity is already being stored as multipliers instead of grams. If so, create a data migration script. -Task 7.2: Create Alembic migration (documentation only) -python"""clarify quantity fields represent grams - -Revision ID: xxxxx -Revises: 2295851db11e -Create Date: 2025-10-01 -""" - -def upgrade() -> None: - # No schema changes needed - # This migration documents that all quantity fields = grams - # If data migration is needed, add conversion logic here - pass - -def downgrade() -> None: - pass - -Testing Checklist - - Add 100g of a food with 100 cal/100g → should show 100 cal - Add 200g of a food with 100 cal/100g → should show 200 cal - Add 50g of a food with 100 cal/100g → should show 50 cal - Import meals from CSV with gram values → should calculate correctly - View detailed page for template → should show grams and correct totals - View detailed page for tracked day → should show grams and correct totals - Edit meal food quantity → should accept and store grams - - -Rollout Plan - -Deploy to staging: Test all functionality manually -Run automated tests: Verify calculations -Check existing data: Ensure no corruption -Deploy to production: Monitor for errors -Document changes: Update any user documentation - - -Risk Assessment -Low Risk: - -Adding documentation -Fixing calculation functions (if current behavior is already treating quantity as grams) - -Medium Risk: - -Changing API parameter names from quantity to grams -Updating templates - -High Risk: - -If existing database has mixed data (some quantities are multipliers, some are grams) -Need to audit actual database content before proceeding - - -Notes - -The CSV import already seems to expect grams based on the code -The main issue appears to be in calculate_meal_nutrition() if it's not properly converting grams to multipliers -Consider adding database constraints or validation to ensure quantity > 0 and reasonable ranges \ No newline at end of file diff --git a/templates/error.html b/templates/error.html new file mode 100644 index 0000000..2c7d0a4 --- /dev/null +++ b/templates/error.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} + +{% block content %} +
    +
    +

    + {{ error_title }} +

    +

    {{ error_message }}

    + {% if error_details %} +
    +

    + Details: {{ error_details }} +

    + {% endif %} +
    + +
    +
    +
    What to do next?
    +
    +
    +
      +
    • Check if the database is properly connected
    • +
    • Verify that all required database tables exist
    • +
    • Try refreshing the page
    • +
    • If the error persists, check the server logs for more details
    • +
    + + +
    +
    +
    +{% endblock %} \ No newline at end of file diff --git a/templates/tracker.html b/templates/tracker.html index 8767cea..cb574dd 100644 --- a/templates/tracker.html +++ b/templates/tracker.html @@ -79,13 +79,36 @@
    + {% set overrides = {} %} + {% for tmf in tracked_meal.tracked_foods %} + {% if not tmf.is_deleted %} + {% set _ = overrides.update({tmf.food_id: tmf}) %} + {% endif %} + {% endfor %} + + {% set displayed_food_ids = [] %} + + {# Display base meal foods, applying overrides #} {% for meal_food in tracked_meal.meal.meal_foods %} -
    -
    - • {{ meal_food.food.name }} - {{ meal_food.quantity }} {{ meal_food.food.serving_unit }} + {% if meal_food.food_id not in overrides %} +
    +
    + • {{ meal_food.food.name }} + {{ meal_food.quantity }} {{ meal_food.food.serving_unit }} +
    +
    + {% endif %} + {% set _ = displayed_food_ids.append(meal_food.food_id) %} + {% endfor %} + + {# Display overridden and new foods #} + {% for food_id, tmf in overrides.items() %} +
    +
    + • {{ tmf.food.name }} + {{ tmf.quantity }} g +
    -
    {% endfor %}
    {% if not tracked_meal.meal.meal_foods %} @@ -376,13 +399,19 @@ const foods = []; inputs.forEach(input => { - foods.push({ + const foodData = { id: parseInt(input.dataset.itemId), food_id: parseInt(input.dataset.foodId), - quantity: parseFloat(input.value), // Quantity is now grams + grams: parseFloat(input.value), // Renamed to grams to match backend is_custom: input.dataset.isCustom === 'true' - }); + }; + foods.push(foodData); }); + + console.log('Payload being sent to /tracker/update_tracked_meal_foods:', JSON.stringify({ + tracked_meal_id: trackedMealId, + foods: foods + }, null, 2)); try { const response = await fetch('/tracker/update_tracked_meal_foods', { @@ -447,19 +476,6 @@ } } - // Update quantity on input change (real-time update) - document.addEventListener('input', function(e) { - if (e.target.type === 'number' && e.target.dataset.foodId) { - const trackedFoodId = e.target.dataset.foodId; - const quantity = parseFloat(e.target.value); - - fetch('/tracker/update_tracked_food', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tracked_food_id: trackedFoodId, quantity: quantity }) - }).catch(error => console.error('Error updating quantity:', error)); - } - }); // Show add single food modal and pre-select meal time function addSingleFoodToTime(mealTime) { document.getElementById('addSingleFoodMealTime').value = mealTime; diff --git a/test_edit_meal.sh b/test_edit_meal.sh new file mode 100755 index 0000000..61edbbe --- /dev/null +++ b/test_edit_meal.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# Curl-based test script for exercising the Edit Tracked Meal modal functionality. +# This script demonstrates how to: +# 1. Update the quantity of a food in a tracked meal. +# 2. Remove a food from a tracked meal. +# 3. Add a new food to a tracked meal. +# +# Prerequisites: +# - The FastAPI server must be running (e.g., uvicorn main:app --reload --port 8000). +# - You need a valid tracked_meal_id (from the database or API response). +# - You need valid food_id(s) (from the foods table or API). +# +# How to find IDs: +# 1. tracked_meal_id: Use the GET /tracker endpoint or query the database: +# SELECT id FROM tracked_meal WHERE tracked_day_id = (SELECT id FROM tracked_day WHERE person = 'Sarah' AND date = '2025-10-02'); +# 2. food_id: Use the GET /api/foods endpoint or query: +# SELECT id, name FROM food LIMIT 5; +# +# Base URL (adjust if your server is on a different host/port) +BASE_URL="http://localhost:8999" + +# Set your specific IDs here (replace with actual values) +TRACKED_MEAL_ID=1 # Example: Replace with your tracked_meal_id +FOOD_ID_TO_UPDATE=1 # Example: Food to update quantity +FOOD_ID_TO_REMOVE=2 # Example: Food to remove +FOOD_ID_TO_ADD=3 # Example: New food to add + +echo "Testing Edit Tracked Meal functionality..." +echo "Using tracked_meal_id: $TRACKED_MEAL_ID" +echo "" + +# 1. Update Quantity of a Food +echo "1. Updating quantity of food $FOOD_ID_TO_UPDATE to 200g..." +curl -X POST "$BASE_URL/tracker/update_tracked_meal_foods" \ + -H "Content-Type: application/json" \ + -d '{ + "tracked_meal_id": '"$TRACKED_MEAL_ID"', + "foods": [ + { + "food_id": '"$FOOD_ID_TO_UPDATE"', + "grams": 200.0 + } + ], + "removed_food_ids": [] + }' + +echo "" +echo "Expected response: {\"status\": \"success\"}" +echo "" + +# Verify the update (optional: GET the foods) +echo "2. Verifying updated foods..." +curl -X GET "$BASE_URL/tracker/get_tracked_meal_foods/$TRACKED_MEAL_ID" + +echo "" +echo "----------------------------------------" +echo "" + +# 3. Remove a Food +echo "3. Removing food $FOOD_ID_TO_REMOVE..." +curl -X POST "$BASE_URL/tracker/update_tracked_meal_foods" \ + -H "Content-Type: application/json" \ + -d '{ + "tracked_meal_id": '"$TRACKED_MEAL_ID"', + "foods": [], + "removed_food_ids": ['"$FOOD_ID_TO_REMOVE"'] + }' + +echo "" +echo "Expected response: {\"status\": \"success\"}" +echo "" + +# Verify removal +echo "4. Verifying removed foods..." +curl -X GET "$BASE_URL/tracker/get_tracked_meal_foods/$TRACKED_MEAL_ID" + +echo "" +echo "----------------------------------------" +echo "" + +# 5. Add a New Food +echo "5. Adding new food $FOOD_ID_TO_ADD with 150g..." +curl -X POST "$BASE_URL/tracker/add_food_to_tracked_meal" \ + -H "Content-Type: application/json" \ + -d '{ + "tracked_meal_id": '"$TRACKED_MEAL_ID"', + "food_id": '"$FOOD_ID_TO_ADD"', + "grams": 150.0 + }' + +echo "" +echo "Expected response: {\"status\": \"success\"}" +echo "" + +# Verify addition +echo "6. Verifying added foods..." +curl -X GET "$BASE_URL/tracker/get_tracked_meal_foods/$TRACKED_MEAL_ID" + +echo "" +echo "Script completed. Check responses for success/error messages." +echo "Note: After running, the tracked day will be marked as modified." \ No newline at end of file diff --git a/tests/test_edit_tracked_meal.py b/tests/test_edit_tracked_meal.py index ec9d3dd..55240cf 100644 --- a/tests/test_edit_tracked_meal.py +++ b/tests/test_edit_tracked_meal.py @@ -89,72 +89,63 @@ def test_get_tracked_meal_foods_endpoint(client: TestClient, session: TestingSes def test_edit_tracked_meal_with_override_flow(client: TestClient, session: TestingSessionLocal): """ - Test the full flow of editing a tracked meal, overriding a food, and then retrieving its foods. - This test aims to reproduce the "Error loading tracked meal foods" bug. + Test the full flow of editing a tracked meal, overriding a food's quantity, + and verifying the new override system. """ food1, food2, meal1, tracked_day, tracked_meal = create_test_data(session) - # 1. Simulate adding a meal (already done by create_test_data, so tracked_meal exists) - # 2. Simulate updating a food in the tracked meal to create an override - # This will call /tracker/update_tracked_meal_foods - - # Get the original MealFood for food1 + # 1. Get the original MealFood for food1 (Apple) original_meal_food1 = session.query(MealFood).filter( MealFood.meal_id == meal1.id, MealFood.food_id == food1.id ).first() assert original_meal_food1 is not None - # Prepare update data: update food1 quantity (should create a TrackedMealFood and delete original MealFood) + # 2. Prepare update data: update food1's quantity and keep food2 the same. updated_foods_data = [ - {"id": original_meal_food1.id, "food_id": food1.id, "grams": 175.0, "is_custom": False}, # Original MealFood, but quantity changed - {"id": None, "food_id": food2.id, "grams": 100.0, "is_custom": False} # Unchanged original MealFood + {"id": original_meal_food1.id, "food_id": food1.id, "grams": 175.0, "is_custom": False}, ] + # 3. Call the update endpoint response_update = client.post( "/tracker/update_tracked_meal_foods", json={ "tracked_meal_id": tracked_meal.id, - "foods": updated_foods_data + "foods": updated_foods_data, + "removed_food_ids": [] } ) assert response_update.status_code == 200 assert response_update.json()["status"] == "success" - session.expire_all() # Ensure a fresh load from the database + session.expire_all() - # Verify original MealFood for food1 is deleted - deleted_meal_food1 = session.query(MealFood).filter(MealFood.id == original_meal_food1.id).first() - assert deleted_meal_food1 is None - - # Verify a TrackedMealFood for food1 now exists - overridden_tracked_food1 = session.query(TrackedMealFood).filter( + # 4. Verify that a new TrackedMealFood override was created for food1 + override_food = session.query(TrackedMealFood).filter( TrackedMealFood.tracked_meal_id == tracked_meal.id, TrackedMealFood.food_id == food1.id ).first() - assert overridden_tracked_food1 is not None - assert overridden_tracked_food1.quantity == 175.0 + assert override_food is not None + assert override_food.quantity == 175.0 + assert override_food.is_override is True - # 3. Now, try to get the tracked meal foods again, which is where the bug occurs - # This will call /tracker/get_tracked_meal_foods + # 5. Verify the original MealFood still exists + assert session.query(MealFood).filter(MealFood.id == original_meal_food1.id).first() is not None + + # 6. Get the foods for the tracked meal and check the final state response_get = client.get(f"/tracker/get_tracked_meal_foods/{tracked_meal.id}") assert response_get.status_code == 200 data_get = response_get.json() assert data_get["status"] == "success" assert len(data_get["meal_foods"]) == 2 - # Verify the contents of the returned meal_foods - food_names = [f["food_name"] for f in data_get["meal_foods"]] - assert "Apple" in food_names - assert "Banana" in food_names - - for food_data in data_get["meal_foods"]: - if food_data["food_name"] == "Apple": - assert food_data["quantity"] == 175.0 - assert food_data["is_custom"] == True - elif food_data["food_name"] == "Banana": - assert food_data["quantity"] == 100.0 - assert food_data["is_custom"] == False + food_map = {f["food_name"]: f for f in data_get["meal_foods"]} + assert "Apple" in food_map + assert "Banana" in food_map + assert food_map["Apple"]["quantity"] == 175.0 + assert food_map["Apple"]["is_custom"] is True # It's an override + assert food_map["Banana"]["quantity"] == 100.0 + assert food_map["Banana"]["is_custom"] is False # It's from the base meal def test_update_tracked_meal_foods_endpoint(client: TestClient, session: TestingSessionLocal): @@ -217,30 +208,120 @@ def test_add_food_to_tracked_meal_endpoint(client: TestClient, session: TestingS data = response.json() assert data["status"] == "success" - # Verify the food was added to the meal associated with the tracked meal - updated_meal_foods = session.query(MealFood).filter(MealFood.meal_id == meal1.id).all() - assert len(updated_meal_foods) == 3 # Original 2 + new 1 - - # Check the new food's quantity - orange_meal_food = next(mf for mf in updated_meal_foods if mf.food_id == food3.id) - assert orange_meal_food.quantity == 200 - -def test_remove_food_from_tracked_meal_endpoint(client: TestClient, session: TestingSessionLocal): - """Test removing a food from a tracked meal""" - food1, food2, meal1, tracked_day, tracked_meal = create_test_data(session) - - # Get the meal_food_id for food1 - meal_food_to_remove = session.query(MealFood).filter( - MealFood.meal_id == meal1.id, - MealFood.food_id == food1.id + # Verify the food was added as a TrackedMealFood, not a MealFood + new_tracked_food = session.query(TrackedMealFood).filter( + TrackedMealFood.tracked_meal_id == tracked_meal.id, + TrackedMealFood.food_id == food3.id ).first() - - response = client.delete(f"/tracker/remove_food_from_tracked_meal/{meal_food_to_remove.id}") - assert response.status_code == 200 - data = response.json() - assert data["status"] == "success" + assert new_tracked_food is not None + assert new_tracked_food.quantity == 200 + assert new_tracked_food.is_override is False # It's a new addition - # Verify the food was removed from the meal associated with the tracked meal - updated_meal_foods = session.query(MealFood).filter(MealFood.meal_id == meal1.id).all() - assert len(updated_meal_foods) == 1 # Original 2 - removed 1 - assert updated_meal_foods[0].food_id == food2.id # Only food2 should remain \ No newline at end of file + # Verify the base meal is unchanged + base_meal_foods = session.query(MealFood).filter(MealFood.meal_id == meal1.id).all() + assert len(base_meal_foods) == 2 + +def test_edit_tracked_meal_bug_scenario(client: TestClient, session: TestingSessionLocal): + """ + Simulates the full bug scenario described: + 1. Start with a meal with 2 foods. + 2. Add a 3rd food. + 3. Delete one of the original foods. + 4. Update the quantity of the other original food. + 5. Save and verify the state. + """ + food1, food2, meal1, tracked_day, tracked_meal = create_test_data(session) + + # 1. Initial state: tracked_meal with food1 (Apple) and food2 (Banana) + + # 2. Add a 3rd food (Orange) + food3 = Food(name="Orange", serving_size=130, serving_unit="g", calories=62, protein=1.2, carbs=15, fat=0.2) + session.add(food3) + session.commit() + session.refresh(food3) + + add_food_payload = { + "tracked_meal_id": tracked_meal.id, + "food_id": food3.id, + "grams": 200 + } + response_add = client.post("/tracker/add_food_to_tracked_meal", json=add_food_payload) + assert response_add.status_code == 200 + assert response_add.json()["status"] == "success" + + # Verify Orange was added as a TrackedMealFood + orange_tmf = session.query(TrackedMealFood).filter( + TrackedMealFood.tracked_meal_id == tracked_meal.id, + TrackedMealFood.food_id == food3.id + ).first() + assert orange_tmf is not None + assert orange_tmf.quantity == 200 + + # 3. Delete an original food (Apple, food1) + # This requires an update call with the food removed from the list + + # 4. Update quantity of the other original food (Banana, food2) + + # Simulate the data sent from the frontend after edits + final_foods_payload = [ + # food1 (Apple) is omitted, signifying deletion + {"id": None, "food_id": food2.id, "grams": 125.0, "is_custom": False}, # Banana quantity updated + {"id": orange_tmf.id, "food_id": food3.id, "grams": 210.0, "is_custom": True} # Orange quantity updated + ] + + removed_food_ids = [food1.id] + + update_payload = { + "tracked_meal_id": tracked_meal.id, + "foods": final_foods_payload, + "removed_food_ids": removed_food_ids + } + + response_update = client.post("/tracker/update_tracked_meal_foods", json=update_payload) + assert response_update.status_code == 200 + assert response_update.json()["status"] == "success" + + session.expire_all() + + # 5. Verify the final state + + # There should be one override for the deleted food (Apple) + deleted_apple_override = session.query(TrackedMealFood).filter( + TrackedMealFood.tracked_meal_id == tracked_meal.id, + TrackedMealFood.food_id == food1.id, + TrackedMealFood.is_deleted == True + ).first() + assert deleted_apple_override is not None + + # There should be one override for the updated food (Banana) + updated_banana_override = session.query(TrackedMealFood).filter( + TrackedMealFood.tracked_meal_id == tracked_meal.id, + TrackedMealFood.food_id == food2.id + ).first() + assert updated_banana_override is not None + assert updated_banana_override.quantity == 125.0 + + # The added food (Orange) should be updated + updated_orange_tmf = session.query(TrackedMealFood).filter( + TrackedMealFood.id == orange_tmf.id + ).first() + assert updated_orange_tmf is not None + assert updated_orange_tmf.quantity == 210.0 + + # Let's check the get_tracked_meal_foods endpoint to be sure + response_get = client.get(f"/tracker/get_tracked_meal_foods/{tracked_meal.id}") + assert response_get.status_code == 200 + data = response_get.json() + assert data["status"] == "success" + + # The final list should contain Banana and Orange, but not Apple + final_food_names = [f["food_name"] for f in data["meal_foods"]] + assert "Apple" not in final_food_names + assert "Banana" in final_food_names + assert "Orange" in final_food_names + + for food_data in data["meal_foods"]: + if food_data["food_name"] == "Banana": + assert food_data["quantity"] == 125.0 + elif food_data["food_name"] == "Orange": + assert food_data["quantity"] == 210.0 \ No newline at end of file diff --git a/tracker.patch b/tracker.patch deleted file mode 100644 index ff87b6a..0000000 --- a/tracker.patch +++ /dev/null @@ -1,115 +0,0 @@ ---- app/api/routes/tracker.py -+++ app/api/routes/tracker.py -@@ -1,4 +1,4 @@ --from fastapi import APIRouter, Depends, HTTPException, Request, Form, Body -+from fastapi import APIRouter, Depends, HTTPException, Request, Form, Body, UploadFile, File - from fastapi.responses import HTMLResponse, RedirectResponse - from sqlalchemy.orm import Session, joinedload - from datetime import date, datetime, timedelta -@@ -110,4 +110,94 @@ - except Exception as e: - db.rollback() - logging.error(f"debug: error removing tracked meal: {e}") -- return {"status": "error", "message": str(e)} -+ return {"status": "error", "message": str(e)} -+ -+@router.post("/tracker/save_template") -+async def tracker_save_template(request: Request, db: Session = Depends(get_db)): -+ """save current day's meals as template""" -+ try: -+ form_data = await request.form() -+ person = form_data.get("person") -+ date_str = form_data.get("date") -+ template_name = form_data.get("template_name") -+ logging.info(f"debug: saving template - name={template_name}, person={person}, date={date_str}") -+ -+ # Parse date -+ from datetime import datetime -+ date = datetime.fromisoformat(date_str).date() -+ -+ # Get tracked day and meals -+ tracked_day = db.query(TrackedDay).filter( -+ TrackedDay.person == person, -+ TrackedDay.date == date -+ ).first() -+ if not tracked_day: -+ return {"status": "error", "message": "No tracked day found"} -+ -+ tracked_meals = db.query(TrackedMeal).filter( -+ TrackedMeal.tracked_day_id == tracked_day.id -+ ).all() -+ -+ if not tracked_meals: -+ return {"status": "error", "message": "No tracked meals found"} -+ -+ # Create new template -+ template = Template(name=template_name) -+ db.add(template) -+ db.flush() -+ -+ # Add meals to template -+ for tracked_meal in tracked_meals: -+ template_meal = TemplateMeal( -+ template_id=template.id, -+ meal_id=tracked_meal.meal_id, -+ meal_time=tracked_meal.meal_time -+ ) -+ db.add(template_meal) -+ -+ db.commit() -+ return {"status": "success", "message": "Template saved successfully"} -+ except Exception as e: -+ db.rollback() -+ logging.error(f"debug: error saving template: {e}") -+ return {"status": "error", "message": str(e)} -+ -+@router.post("/tracker/apply_template") -+async def tracker_apply_template(request: Request, db: Session = Depends(get_db)): -+ """apply template to current day""" -+ try: -+ form_data = await request.form() -+ person = form_data.get("person") -+ date_str = form_data.get("date") -+ template_id = form_data.get("template_id") -+ logging.info(f"debug: applying template - template_id={template_id}, person={person}, date={date_str}") -+ -+ # Parse date -+ from datetime import datetime -+ date = datetime.fromisoformat(date_str).date() -+ -+ # Get template meals -+ template_meals = db.query(TemplateMeal).filter( -+ TemplateMeal.template_id == template_id -+ ).all() -+ -+ if not template_meals: -+ return {"status": "error", "message": "Template has no meals"} -+ -+ # Get or create tracked day -+ tracked_day = db.query(TrackedDay).filter( -+ TrackedDay.person == person, -+ TrackedDay.date == date -+ ).first() -+ if not tracked_day: -+ tracked_day = TrackedDay(person=person, date=date, is_modified=True) -+ db.add(tracked_day) -+ db.flush() -+ -+ # Clear existing meals and add template meals -+ db.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).delete() -+ -+ for template_meal in template_meals: -+ tracked_meal = TrackedMeal( -+ tracked_day_id=tracked_day.id, -+ meal_id=template_meal.meal_id, -+ meal_time=template_meal.meal_time -+ ) -+ db.add(tracked_meal) -+ -+ tracked_day.is_modified = True -+ db.commit() -+ return {"status": "success", "message": "Template applied successfully"} -+ except Exception as e: -+ db.rollback() -+ logging.error(f"debug: error applying template: {e}") -+ return {"status": "error", "message": str(e)} \ No newline at end of file