From 326a82ea5d050546028b2680aacd50c521ec1297 Mon Sep 17 00:00:00 2001 From: sstent Date: Tue, 24 Feb 2026 07:18:10 -0800 Subject: [PATCH 01/11] feat(phase): Complete Phase 1: Preparation & Schema Updates --- .../7fdcc454e056_add_name_to_tracked_meal.py | 27 +++++++++++++++++++ app/database.py | 3 ++- conductor/tracks.md | 2 +- .../meal_tracker_refactor_20250223/plan.md | 6 ++--- 4 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 alembic/versions/7fdcc454e056_add_name_to_tracked_meal.py diff --git a/alembic/versions/7fdcc454e056_add_name_to_tracked_meal.py b/alembic/versions/7fdcc454e056_add_name_to_tracked_meal.py new file mode 100644 index 0000000..e37f253 --- /dev/null +++ b/alembic/versions/7fdcc454e056_add_name_to_tracked_meal.py @@ -0,0 +1,27 @@ +"""add_name_to_tracked_meal + +Revision ID: 7fdcc454e056 +Revises: e1c2d8d5c1a8 +Create Date: 2026-02-24 06:29:46.441129 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '7fdcc454e056' +down_revision: Union[str, None] = 'e1c2d8d5c1a8' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column('tracked_meals', sa.Column('name', sa.String(), nullable=True)) + + +def downgrade() -> None: + with op.batch_alter_table('tracked_meals') as batch_op: + batch_op.drop_column('name') diff --git a/app/database.py b/app/database.py index 8aa270a..abd838e 100644 --- a/app/database.py +++ b/app/database.py @@ -148,8 +148,9 @@ class TrackedMeal(Base): id = Column(Integer, primary_key=True, index=True) tracked_day_id = Column(Integer, ForeignKey("tracked_days.id")) - meal_id = Column(Integer, ForeignKey("meals.id")) + meal_id = Column(Integer, ForeignKey("meals.id"), nullable=True) meal_time = Column(String) # Breakfast, Lunch, Dinner, Snack 1, Snack 2, Beverage 1, Beverage 2 + name = Column(String, nullable=True) # For single food items or custom names tracked_day = relationship("TrackedDay", back_populates="tracked_meals") meal = relationship("Meal") diff --git a/conductor/tracks.md b/conductor/tracks.md index 72ca727..16376e6 100644 --- a/conductor/tracks.md +++ b/conductor/tracks.md @@ -4,5 +4,5 @@ This file tracks all major tracks for the project. Each track has its own detail --- -- [ ] **Track: Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes'** +- [~] **Track: Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes'** *Link: [./tracks/meal_tracker_refactor_20250223/](./tracks/meal_tracker_refactor_20250223/)* diff --git a/conductor/tracks/meal_tracker_refactor_20250223/plan.md b/conductor/tracks/meal_tracker_refactor_20250223/plan.md index 4d4d1c4..6026e1d 100644 --- a/conductor/tracks/meal_tracker_refactor_20250223/plan.md +++ b/conductor/tracks/meal_tracker_refactor_20250223/plan.md @@ -3,9 +3,9 @@ This plan outlines the steps for refactoring the meal tracking system to decouple "Journal Logs" from "Cookbook Recipes," resolving database pollution and improving system structure. ## Phase 1: Preparation & Schema Updates -- [ ] Task: Create a new branch for the refactoring track. -- [ ] Task: Add the 'name' column to the 'TrackedMeal' table and make 'meal_id' nullable in 'app/database.py'. -- [ ] Task: Create and run an Alembic migration for the schema changes. +- [x] Task: Create a new branch for the refactoring track. +- [x] Task: Add the 'name' column to the 'TrackedMeal' table and make 'meal_id' nullable in 'app/database.py'. +- [x] Task: Create and run an Alembic migration for the schema changes. - [ ] Task: Conductor - User Manual Verification 'Phase 1: Preparation & Schema Updates' (Protocol in workflow.md) ## Phase 2: Logic & Calculation Updates From f0430c810bcb21b0815e43444eb3847d2d39a50c Mon Sep 17 00:00:00 2001 From: sstent Date: Tue, 24 Feb 2026 07:18:19 -0800 Subject: [PATCH 02/11] conductor(plan): Mark phase 'Phase 1: Preparation & Schema Updates' as complete --- conductor/tracks/meal_tracker_refactor_20250223/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conductor/tracks/meal_tracker_refactor_20250223/plan.md b/conductor/tracks/meal_tracker_refactor_20250223/plan.md index 6026e1d..3f81b0f 100644 --- a/conductor/tracks/meal_tracker_refactor_20250223/plan.md +++ b/conductor/tracks/meal_tracker_refactor_20250223/plan.md @@ -2,7 +2,7 @@ This plan outlines the steps for refactoring the meal tracking system to decouple "Journal Logs" from "Cookbook Recipes," resolving database pollution and improving system structure. -## Phase 1: Preparation & Schema Updates +## Phase 1: Preparation & Schema Updates [checkpoint: 326a82e] - [x] Task: Create a new branch for the refactoring track. - [x] Task: Add the 'name' column to the 'TrackedMeal' table and make 'meal_id' nullable in 'app/database.py'. - [x] Task: Create and run an Alembic migration for the schema changes. From cc6b4ca1456d5d73408f89e716f99f804e06b610 Mon Sep 17 00:00:00 2001 From: sstent Date: Tue, 24 Feb 2026 08:19:03 -0800 Subject: [PATCH 03/11] feat(phase): Complete Phase 2: Logic & Calculation Updates --- app/api/routes/tracker.py | 28 ++++--- app/database.py | 6 +- tests/test_add_food_bug.py | 27 +++--- tests/test_food_weight_consistency.py | 9 +- tests/test_tracked_meal_refactor.py | 113 ++++++++++++++++++++++++++ tests/test_tracker.py | 29 ++++--- 6 files changed, 166 insertions(+), 46 deletions(-) create mode 100644 tests/test_tracked_meal_refactor.py diff --git a/app/api/routes/tracker.py b/app/api/routes/tracker.py index 7dbba84..f444ada 100644 --- a/app/api/routes/tracker.py +++ b/app/api/routes/tracker.py @@ -746,23 +746,25 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db) # Store grams directly quantity = grams - # Create a new Meal for this single food entry - # This allows it to be treated like any other meal in the tracker view - new_meal = Meal(name=food_item.name, meal_type="single_food", meal_time=meal_time) - db.add(new_meal) - db.flush() # Flush to get the new meal ID - - # Link the food to the new meal - meal_food = MealFood(meal_id=new_meal.id, food_id=food_id, quantity=grams) - db.add(meal_food) - - # Create tracked meal entry + # Create tracked meal entry without a parent Meal template tracked_meal = TrackedMeal( tracked_day_id=tracked_day.id, - meal_id=new_meal.id, - meal_time=meal_time + meal_id=None, + meal_time=meal_time, + name=food_item.name ) db.add(tracked_meal) + db.flush() # Flush to get the tracked_meal ID + + # Link the food directly to the tracked meal via TrackedMealFood + new_entry = TrackedMealFood( + tracked_meal_id=tracked_meal.id, + food_id=food_id, + quantity=grams, + is_override=False, + is_deleted=False + ) + db.add(new_entry) # Mark day as modified tracked_day.is_modified = True diff --git a/app/database.py b/app/database.py index abd838e..dcb4291 100644 --- a/app/database.py +++ b/app/database.py @@ -428,9 +428,11 @@ def calculate_tracked_meal_nutrition(tracked_meal, db: Session): 'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0 } - # 1. Get base foods from the meal + # 1. Get base foods from the meal (if it exists) # access via relationship, assume eager loading or lazy loading - base_foods = {mf.food_id: mf for mf in tracked_meal.meal.meal_foods} + base_foods = {} + if tracked_meal.meal: + base_foods = {mf.food_id: mf for mf in tracked_meal.meal.meal_foods} # 2. Get tracked foods (overrides, deletions, additions) tracked_foods = tracked_meal.tracked_foods diff --git a/tests/test_add_food_bug.py b/tests/test_add_food_bug.py index b57b4bb..5490b20 100644 --- a/tests/test_add_food_bug.py +++ b/tests/test_add_food_bug.py @@ -98,20 +98,9 @@ def test_add_food_quantity_saved_correctly(test_client: TestClient, test_engine) query_session = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)() try: - # Find the created Meal - created_meal = query_session.query(Meal).order_by(Meal.id.desc()).first() - assert created_meal is not None - assert created_meal.name == "Test Food" - assert created_meal.meal_type == "single_food" - - # Find the MealFood - meal_food = query_session.query(MealFood).filter(MealFood.meal_id == created_meal.id).first() - assert meal_food is not None - assert meal_food.food_id == food.id - - # This assertion fails because the backend used data.get("grams", 1.0), so quantity=1.0 instead of 50.0 - # After the fix changing to data.get("quantity", 1.0), it will pass - assert meal_food.quantity == 50.0, f"Expected quantity 50.0, but got {meal_food.quantity}" + # Verify NO new Meal was created + meals = query_session.query(Meal).all() + assert len(meals) == 0 # Also verify TrackedDay and TrackedMeal were created tracked_day = query_session.query(TrackedDay).filter( @@ -123,8 +112,16 @@ def test_add_food_quantity_saved_correctly(test_client: TestClient, test_engine) tracked_meal = query_session.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).first() assert tracked_meal is not None - assert tracked_meal.meal_id == created_meal.id + assert tracked_meal.meal_id is None + assert tracked_meal.name == "Test Food" assert tracked_meal.meal_time == "Snack 1" + + # Find the TrackedMealFood + from app.database import TrackedMealFood + tmf = query_session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first() + assert tmf is not None + assert tmf.food_id == food.id + assert tmf.quantity == 50.0 finally: query_session.close() diff --git a/tests/test_food_weight_consistency.py b/tests/test_food_weight_consistency.py index 4e592a7..8e20dff 100644 --- a/tests/test_food_weight_consistency.py +++ b/tests/test_food_weight_consistency.py @@ -140,10 +140,13 @@ def test_tracker_add_food_grams_input(client, session, sample_food_100g): assert response.json()["status"] == "success" # Verify the tracked meal food quantity - tracked_meal = session.query(Meal).filter(Meal.name == sample_food_100g.name).first() + tracked_meal = session.query(TrackedMeal).filter(TrackedMeal.name == sample_food_100g.name).first() assert tracked_meal is not None - meal_food = session.query(MealFood).filter(MealFood.meal_id == tracked_meal.id).first() - assert meal_food.quantity == grams + assert tracked_meal.meal_id is None + + tmf = session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first() + assert tmf is not None + assert tmf.quantity == grams def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g, sample_food_50g): """Test updating tracked meal foods with grams input""" diff --git a/tests/test_tracked_meal_refactor.py b/tests/test_tracked_meal_refactor.py new file mode 100644 index 0000000..480a617 --- /dev/null +++ b/tests/test_tracked_meal_refactor.py @@ -0,0 +1,113 @@ +import pytest +from app.database import Food, TrackedMeal, TrackedMealFood, calculate_tracked_meal_nutrition +from sqlalchemy.orm import Session + +def test_calculate_tracked_meal_nutrition_no_meal_template(db_session: Session): + """Test nutrition calculation for a tracked meal with no parent meal template (meal_id=None)""" + # Create a food + food = Food( + name="Test Food", + serving_size=100.0, + serving_unit="g", + calories=100.0, + protein=10.0, + carbs=20.0, + fat=5.0, + fiber=5.0, + sugar=10.0, + sodium=100.0, + calcium=50.0 + ) + db_session.add(food) + db_session.commit() + db_session.refresh(food) + + # Create a tracked meal without a template + tracked_meal = TrackedMeal( + meal_id=None, + meal_time="Snack", + name="Single Food Log" + ) + db_session.add(tracked_meal) + db_session.commit() + db_session.refresh(tracked_meal) + + # Add a tracked food entry to it + tracked_food = TrackedMealFood( + tracked_meal_id=tracked_meal.id, + food_id=food.id, + quantity=200.0, # 2 servings + is_override=False, + is_deleted=False + ) + db_session.add(tracked_food) + db_session.commit() + db_session.refresh(tracked_food) + + # Calculate nutrition + nutrition = calculate_tracked_meal_nutrition(tracked_meal, db_session) + + # Assertions + assert nutrition['calories'] == 200.0 + assert nutrition['protein'] == 20.0 + assert nutrition['carbs'] == 40.0 + assert nutrition['fat'] == 10.0 + assert nutrition['fiber'] == 10.0 + assert nutrition['sugar'] == 20.0 + assert nutrition['sodium'] == 200.0 + assert nutrition['calcium'] == 100.0 + assert nutrition['net_carbs'] == 30.0 + assert nutrition['protein_pct'] == 40.0 # (20 * 4) / 200 = 80 / 200 = 40% + assert nutrition['carbs_pct'] == 80.0 # (40 * 4) / 200 = 160 / 200 = 80% + assert nutrition['fat_pct'] == 45.0 # (10 * 9) / 200 = 90 / 200 = 45% + +def test_tracker_add_food_api_no_new_meal(client, db_session: Session): + """Test /tracker/add_food endpoint to ensure it doesn't create redundant Meal templates""" + # Create a food + food = Food( + name="API 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() + db_session.refresh(food) + + from app.database import Meal + initial_meal_count = db_session.query(Meal).count() + + # Call the API + response = client.post("/tracker/add_food", json={ + "person": "Sarah", + "date": "2025-02-24", + "food_id": food.id, + "quantity": 150.0, + "meal_time": "Snack" + }) + + assert response.status_code == 200 + assert response.json()["status"] == "success" + + # Verify NO new Meal was created + assert db_session.query(Meal).count() == initial_meal_count + + # Verify TrackedMeal exists with meal_id=None and correct name + from app.database import TrackedMeal, TrackedDay + tracked_day = db_session.query(TrackedDay).filter(TrackedDay.date == "2025-02-24").first() + assert tracked_day is not None + + tracked_meal = db_session.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).first() + assert tracked_meal is not None + assert tracked_meal.meal_id is None + assert tracked_meal.name == "API Test Food" + + # Verify TrackedMealFood exists + from app.database import TrackedMealFood + tmf = db_session.query(TrackedMealFood).filter(TrackedMealFood.tracked_meal_id == tracked_meal.id).first() + assert tmf is not None + assert tmf.food_id == food.id + assert tmf.quantity == 150.0 diff --git a/tests/test_tracker.py b/tests/test_tracker.py index b02f76c..cfd5359 100644 --- a/tests/test_tracker.py +++ b/tests/test_tracker.py @@ -384,12 +384,13 @@ class TestTrackerAddFood: assert len(tracked_meals) == 1 tracked_meal = tracked_meals[0] - assert tracked_meal.meal.name == sample_food.name # The meal name should be the food name + assert tracked_meal.name == sample_food.name # The meal name should be the food name + assert tracked_meal.meal_id is None # Verify the food is in the tracked meal's foods - assert len(tracked_meal.meal.meal_foods) == 1 - assert tracked_meal.meal.meal_foods[0].food_id == sample_food.id - assert tracked_meal.meal.meal_foods[0].quantity == 100.0 + assert len(tracked_meal.tracked_foods) == 1 + assert tracked_meal.tracked_foods[0].food_id == sample_food.id + assert tracked_meal.tracked_foods[0].quantity == 100.0 def test_add_food_to_tracker_with_meal_time(self, client, sample_food, db_session): @@ -418,11 +419,12 @@ class TestTrackerAddFood: assert len(tracked_meals) == 1 tracked_meal = tracked_meals[0] - assert tracked_meal.meal.name == sample_food.name + assert tracked_meal.name == sample_food.name + assert tracked_meal.meal_id is None - assert len(tracked_meal.meal.meal_foods) == 1 - assert tracked_meal.meal.meal_foods[0].food_id == sample_food.id - assert tracked_meal.meal.meal_foods[0].quantity == 150.0 + assert len(tracked_meal.tracked_foods) == 1 + assert tracked_meal.tracked_foods[0].food_id == sample_food.id + assert tracked_meal.tracked_foods[0].quantity == 150.0 def test_add_food_quantity_is_correctly_converted_to_servings(self, client, db_session): """ @@ -464,12 +466,13 @@ class TestTrackerAddFood: assert len(tracked_meals) == 1 tracked_meal = tracked_meals[0] - assert tracked_meal.meal.name == food.name + assert tracked_meal.name == food.name + assert tracked_meal.meal_id is None - # Verify the food is in the tracked meal's foods and quantity is in servings - assert len(tracked_meal.meal.meal_foods) == 1 - assert tracked_meal.meal.meal_foods[0].food_id == food.id - assert tracked_meal.meal.meal_foods[0].quantity == grams_to_add + # Verify the food is in the tracked meal's foods + assert len(tracked_meal.tracked_foods) == 1 + assert tracked_meal.tracked_foods[0].food_id == food.id + assert tracked_meal.tracked_foods[0].quantity == grams_to_add # Verify nutrition calculation day_nutrition = calculate_day_nutrition_tracked([tracked_meal], db_session) From 00600a76fa9cbc5f8033992d7bc19cee9ebc69f9 Mon Sep 17 00:00:00 2001 From: sstent Date: Tue, 24 Feb 2026 08:19:15 -0800 Subject: [PATCH 04/11] conductor(plan): Mark phase 'Phase 2: Logic & Calculation Updates' as complete --- conductor/tracks/meal_tracker_refactor_20250223/plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/conductor/tracks/meal_tracker_refactor_20250223/plan.md b/conductor/tracks/meal_tracker_refactor_20250223/plan.md index 3f81b0f..e0dd9cf 100644 --- a/conductor/tracks/meal_tracker_refactor_20250223/plan.md +++ b/conductor/tracks/meal_tracker_refactor_20250223/plan.md @@ -8,7 +8,7 @@ This plan outlines the steps for refactoring the meal tracking system to decoupl - [x] Task: Create and run an Alembic migration for the schema changes. - [ ] Task: Conductor - User Manual Verification 'Phase 1: Preparation & Schema Updates' (Protocol in workflow.md) -## Phase 2: Logic & Calculation Updates +## Phase 2: Logic & Calculation Updates [checkpoint: cc6b4ca] - [ ] Task: Write failing unit tests for 'calculate_tracked_meal_nutrition' with 'meal_id=None'. - [ ] Task: Implement support for 'meal_id=None' in 'calculate_tracked_meal_nutrition' within 'app/database.py'. - [ ] Task: Write failing unit tests for the refactored 'tracker_add_food' endpoint. From b834e89a970b898bc625176f541a6a3054679980 Mon Sep 17 00:00:00 2001 From: sstent Date: Tue, 24 Feb 2026 08:46:32 -0800 Subject: [PATCH 05/11] feat(phase): Complete Phase 3: UI & Cookbook Refinement --- app/api/routes/meals.py | 5 ++++- templates/tracker.html | 8 +++++--- tests/tracked_meal_refactor.spec.js | 32 +++++++++++++++++++++++++++++ 3 files changed, 41 insertions(+), 4 deletions(-) create mode 100644 tests/tracked_meal_refactor.spec.js diff --git a/app/api/routes/meals.py b/app/api/routes/meals.py index 18320c2..467d9cb 100644 --- a/app/api/routes/meals.py +++ b/app/api/routes/meals.py @@ -15,7 +15,10 @@ router = APIRouter() @router.get("/meals", response_class=HTMLResponse) async def meals_page(request: Request, db: Session = Depends(get_db)): from sqlalchemy.orm import joinedload - meals = db.query(Meal).options(joinedload(Meal.meal_foods).joinedload(MealFood.food)).all() + # Filter out single food entries and snapshots + meals = db.query(Meal).filter( + Meal.meal_type.notin_(["single_food", "tracked_snapshot"]) + ).options(joinedload(Meal.meal_foods).joinedload(MealFood.food)).all() foods = db.query(Food).all() return templates.TemplateResponse("meals.html", {"request": request, "meals": meals, "foods": foods}) diff --git a/templates/tracker.html b/templates/tracker.html index 84ad3dd..682f500 100644 --- a/templates/tracker.html +++ b/templates/tracker.html @@ -70,15 +70,15 @@ {% for tracked_meal in meals_for_time %} {# 1. Create stable slugs #} {% set meal_time_slug = meal_time|slugify %} - {% set meal_name_safe = tracked_meal.meal.name|slugify %} + {% set display_meal_name = (tracked_meal.name or tracked_meal.meal.name) if (tracked_meal.name or tracked_meal.meal) else "Unnamed Meal" %} + {% set meal_name_safe = display_meal_name|slugify %} {# 2. Construct the core Unique Meal ID for non-ambiguous locating #} {% set unique_meal_id = meal_time_slug + '-' + meal_name_safe + '-' + loop.index|string %}
- {{ tracked_meal.meal.name - }} + {{ display_meal_name }}