fix(dev): Resolve conflict and fix syntax in nomad-deploy-dev workflow
All checks were successful
Build and Push Docker Image / build-and-push (push) Successful in 46s

This commit is contained in:
2026-02-24 13:28:26 -08:00
13 changed files with 250 additions and 65 deletions

View File

@@ -51,6 +51,6 @@ jobs:
run: |
echo "Deploying container version: ${{ steps.container_version.outputs.sha }}"
nomad status
nomad job run
-var="container_version=${{ steps.container_version.outputs.sha }}"
nomad job run \
-var="container_version=${{ steps.container_version.outputs.sha }}" \
foodplanner-dev.nomad

View File

@@ -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')

View File

@@ -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})

View File

@@ -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

View File

@@ -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")
@@ -427,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

View File

@@ -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'**
- [x] **Track: Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes'**
*Link: [./tracks/meal_tracker_refactor_20250223/](./tracks/meal_tracker_refactor_20250223/)*

View File

@@ -2,27 +2,27 @@
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.
## 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.
- [ ] 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.
- [ ] Task: Refactor the 'tracker_add_food' route in 'app/api/routes/tracker.py' to use the new 'TrackedMeal' structure.
- [ ] Task: Conductor - User Manual Verification 'Phase 2: Logic & Calculation Updates' (Protocol in workflow.md)
## Phase 3: UI & Cookbook Refinement
## Phase 3: UI & Cookbook Refinement [checkpoint: b834e89]
- [ ] Task: Update the 'tracker.html' template to display 'TrackedMeal.name' for template-less logs.
- [ ] Task: Update the Meals page in 'app/api/routes/meals.py' to filter out 'single_food' and 'snapshot' types.
- [ ] Task: Write failing E2E tests for the new tracking workflow.
- [ ] Task: Conductor - User Manual Verification 'Phase 3: UI & Cookbook Refinement' (Protocol in workflow.md)
## Phase 4: Database Migration & Cleanup
- [ ] Task: Create a Python migration script for cleaning up existing 'single_food' entries.
- [ ] Task: Run the migration script on the development PostgreSQL database.
- [ ] Task: Verify the database state and ensure no orphans remain.
- [ ] Task: Conductor - User Manual Verification 'Phase 4: Database Migration & Cleanup' (Protocol in workflow.md)
## Phase 4: Database Migration & Cleanup [checkpoint: 5c73ce9]
- [x] Task: Create a Python migration script for cleaning up existing 'single_food' entries.
- [x] Task: Run the migration script on the development PostgreSQL database.
- [x] Task: Verify the database state and ensure no orphans remain.
- [x] Task: Conductor - User Manual Verification 'Phase 4: Database Migration & Cleanup' (Protocol in workflow.md)

View File

@@ -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 %}
<div class="mb-3 p-3 bg-light rounded" data-testid="meal-card-{{ unique_meal_id }}">
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<strong data-testid="meal-name-{{ unique_meal_id }}">{{ tracked_meal.meal.name
}}</strong>
<strong data-testid="meal-name-{{ unique_meal_id }}">{{ display_meal_name }}</strong>
</div>
<div>
<button class="btn btn-sm btn-outline-secondary me-1"
@@ -126,6 +126,7 @@
</thead>
<tbody>
{# Display base meal foods, applying overrides #}
{% if tracked_meal.meal %}
{% for meal_food in tracked_meal.meal.meal_foods %}
{% if meal_food.food_id not in deleted_food_ids and meal_food.food_id not in
overrides.keys() %}
@@ -162,6 +163,7 @@
</tr>
{% endif %}
{% endfor %}
{% endif %}
{# Display overridden/new foods #}
{% for food_id, tmf in overrides.items() %}

View File

@@ -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()

View File

@@ -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"""

View File

@@ -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

View File

@@ -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)

View File

@@ -0,0 +1,32 @@
const { test, expect } = require('@playwright/test');
test('add single food to tracker and verify it is not in meals page', async ({ page }) => {
await page.goto('/tracker');
// Add single food to breakfast
await page.locator('[data-testid="add-food-breakfast"]').click();
// Select a food (Verification Beans)
await page.locator('#addSingleFoodModal select[name="food_id"]').selectOption({ label: 'Verification Beans' });
await page.locator('#addSingleFoodModal input[name="quantity"]').fill('200');
await page.getByRole('button', { name: 'Add Food', exact: true }).click();
// Verify it appears in the tracker
// The name should be just the food name
const mealNameLocator = page.locator('[data-testid^="meal-name-breakfast-verification-beans"]');
await expect(mealNameLocator).toBeVisible();
await expect(mealNameLocator).toHaveText('Verification Beans');
// Verify it contains the food with correct quantity
const foodRowLocator = page.locator('[data-testid^="food-row-breakfast-verification-beans"][data-testid$="verification-beans"]');
await expect(foodRowLocator).toBeVisible();
await expect(foodRowLocator).toContainText('Verification Beans');
await expect(foodRowLocator).toContainText('200.0 g');
// Navigate to Meals page
await page.goto('/meals');
// Verify 'Verification Beans' is NOT in the meals list as a meal name
// It might be in the ingredients dropdown, but shouldn't be a <strong> heading in a card
const mealCardHeading = page.locator('.card-title:has-text("Verification Beans")');
await expect(mealCardHeading).not.toBeVisible();
});