mirror of
https://github.com/sstent/foodplanner.git
synced 2026-03-31 01:25:24 +00:00
Compare commits
14 Commits
8d3e91a825
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e411a8f6c4 | |||
| e68a1f52af | |||
| 7fc17967e0 | |||
| e47af2c839 | |||
| 7099577f92 | |||
| ced3c63f92 | |||
| 5c73ce9caf | |||
| 0e4cb5a5ed | |||
| b834e89a97 | |||
| 00600a76fa | |||
| cc6b4ca145 | |||
| f0430c810b | |||
| 326a82ea5d | |||
| afdf9fa5b7 |
59
.github/workflows/build-and-push-dev.yml
vendored
Normal file
59
.github/workflows/build-and-push-dev.yml
vendored
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
name: Build and Push Docker Image (DEV)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- dev
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build-and-push:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
packages: write
|
||||||
|
outputs:
|
||||||
|
container_sha: ${{ github.sha }}
|
||||||
|
registry_url: ${{ steps.registry.outputs.url }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set registry URL
|
||||||
|
id: registry
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.server_url }}" = "https://github.com" ]; then
|
||||||
|
echo "url=ghcr.io" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "url=${{ github.server_url }}" | sed 's|https://||' >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set up QEMU
|
||||||
|
uses: docker/setup-qemu-action@v3
|
||||||
|
|
||||||
|
- name: Log in to Container Registry
|
||||||
|
uses: docker/login-action@v3
|
||||||
|
with:
|
||||||
|
registry: ${{ steps.registry.outputs.url }}
|
||||||
|
username: ${{ github.actor }}
|
||||||
|
password: ${{ secrets.PACKAGE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
uses: docker/setup-buildx-action@v3
|
||||||
|
|
||||||
|
- name: Build and push multi-arch Docker image
|
||||||
|
uses: docker/build-push-action@v5
|
||||||
|
with:
|
||||||
|
context: .
|
||||||
|
push: true
|
||||||
|
platforms: linux/amd64,linux/arm64
|
||||||
|
tags: |
|
||||||
|
${{ steps.registry.outputs.url }}/${{ github.repository }}:dev
|
||||||
|
${{ steps.registry.outputs.url }}/${{ github.repository }}:${{ github.sha }}
|
||||||
|
build-args: |
|
||||||
|
COMMIT_SHA=${{ github.sha }}
|
||||||
|
cache-from: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache-dev
|
||||||
|
cache-to: type=registry,ref=${{ steps.registry.outputs.url }}/${{ github.repository }}:buildcache-dev,mode=max
|
||||||
|
|
||||||
|
labels: org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
|
||||||
56
.github/workflows/nomad-deploy-dev.yml
vendored
Normal file
56
.github/workflows/nomad-deploy-dev.yml
vendored
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
name: Deploy to Nomad (DEV)
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_run:
|
||||||
|
workflows: ["Build and Push Docker Image (DEV)"] # Must match your build workflow name exactly
|
||||||
|
types:
|
||||||
|
- completed
|
||||||
|
workflow_dispatch: # Allows manual triggering for testing
|
||||||
|
inputs:
|
||||||
|
container_sha:
|
||||||
|
description: 'Container SHA to deploy (leave empty for latest commit)'
|
||||||
|
required: false
|
||||||
|
type: string
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
nomad:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
name: Deploy to Nomad (DEV)
|
||||||
|
# Only run if the build workflow succeeded
|
||||||
|
if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
# 1. Checkout Code
|
||||||
|
- name: Checkout Repository
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# 2. Install Nomad CLI
|
||||||
|
- name: Setup Nomad CLI
|
||||||
|
uses: hashicorp/setup-nomad@main
|
||||||
|
with:
|
||||||
|
version: '1.10.0' # Use your desired version or remove for 'latest'
|
||||||
|
|
||||||
|
# 3. Determine container version to deploy
|
||||||
|
- name: Set Container Version
|
||||||
|
id: container_version
|
||||||
|
run: |
|
||||||
|
if [ "${{ github.event_name }}" = "workflow_dispatch" ] && [ -n "${{ inputs.container_sha }}" ]; then
|
||||||
|
echo "sha=${{ inputs.container_sha }}" >> $GITHUB_OUTPUT
|
||||||
|
elif [ "${{ github.event_name }}" = "workflow_run" ]; then
|
||||||
|
echo "sha=${{ github.event.workflow_run.head_sha }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "sha=${{ github.sha }}" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
# 4. Deploy the Nomad Job
|
||||||
|
- name: Deploy Nomad Job
|
||||||
|
id: deploy
|
||||||
|
env:
|
||||||
|
# REQUIRED: Set the Nomad server address
|
||||||
|
NOMAD_ADDR: http://nomad.service.dc1.consul:4646
|
||||||
|
run: |
|
||||||
|
echo "Deploying container version: ${{ steps.container_version.outputs.sha }}"
|
||||||
|
nomad status
|
||||||
|
nomad job run \
|
||||||
|
-var="container_version=${{ steps.container_version.outputs.sha }}" \
|
||||||
|
foodplanner-dev.nomad
|
||||||
27
alembic/versions/7fdcc454e056_add_name_to_tracked_meal.py
Normal file
27
alembic/versions/7fdcc454e056_add_name_to_tracked_meal.py
Normal 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')
|
||||||
@@ -15,7 +15,10 @@ router = APIRouter()
|
|||||||
@router.get("/meals", response_class=HTMLResponse)
|
@router.get("/meals", response_class=HTMLResponse)
|
||||||
async def meals_page(request: Request, db: Session = Depends(get_db)):
|
async def meals_page(request: Request, db: Session = Depends(get_db)):
|
||||||
from sqlalchemy.orm import joinedload
|
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()
|
foods = db.query(Food).all()
|
||||||
return templates.TemplateResponse("meals.html",
|
return templates.TemplateResponse("meals.html",
|
||||||
{"request": request, "meals": meals, "foods": foods})
|
{"request": request, "meals": meals, "foods": foods})
|
||||||
|
|||||||
@@ -746,23 +746,25 @@ async def tracker_add_food(data: dict = Body(...), db: Session = Depends(get_db)
|
|||||||
# Store grams directly
|
# Store grams directly
|
||||||
quantity = grams
|
quantity = grams
|
||||||
|
|
||||||
# Create a new Meal for this single food entry
|
# Create tracked meal entry without a parent Meal template
|
||||||
# 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
|
|
||||||
tracked_meal = TrackedMeal(
|
tracked_meal = TrackedMeal(
|
||||||
tracked_day_id=tracked_day.id,
|
tracked_day_id=tracked_day.id,
|
||||||
meal_id=new_meal.id,
|
meal_id=None,
|
||||||
meal_time=meal_time
|
meal_time=meal_time,
|
||||||
|
name=food_item.name
|
||||||
)
|
)
|
||||||
db.add(tracked_meal)
|
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
|
# Mark day as modified
|
||||||
tracked_day.is_modified = True
|
tracked_day.is_modified = True
|
||||||
|
|||||||
@@ -148,8 +148,9 @@ class TrackedMeal(Base):
|
|||||||
|
|
||||||
id = Column(Integer, primary_key=True, index=True)
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
tracked_day_id = Column(Integer, ForeignKey("tracked_days.id"))
|
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
|
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")
|
tracked_day = relationship("TrackedDay", back_populates="tracked_meals")
|
||||||
meal = relationship("Meal")
|
meal = relationship("Meal")
|
||||||
@@ -427,8 +428,10 @@ def calculate_tracked_meal_nutrition(tracked_meal, db: Session):
|
|||||||
'fiber': 0, 'sugar': 0, 'sodium': 0, 'calcium': 0
|
'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
|
# access via relationship, assume eager loading or lazy loading
|
||||||
|
base_foods = {}
|
||||||
|
if tracked_meal.meal:
|
||||||
base_foods = {mf.food_id: mf for mf in tracked_meal.meal.meal_foods}
|
base_foods = {mf.food_id: mf for mf in tracked_meal.meal.meal_foods}
|
||||||
|
|
||||||
# 2. Get tracked foods (overrides, deletions, additions)
|
# 2. Get tracked foods (overrides, deletions, additions)
|
||||||
|
|||||||
@@ -3,3 +3,6 @@
|
|||||||
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
This file tracks all major tracks for the project. Each track has its own detailed plan in its respective folder.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
- [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/)*
|
||||||
|
|||||||
5
conductor/tracks/meal_tracker_refactor_20250223/index.md
Normal file
5
conductor/tracks/meal_tracker_refactor_20250223/index.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Track meal_tracker_refactor_20250223 Context
|
||||||
|
|
||||||
|
- [Specification](./spec.md)
|
||||||
|
- [Implementation Plan](./plan.md)
|
||||||
|
- [Metadata](./metadata.json)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"track_id": "meal_tracker_refactor_20250223",
|
||||||
|
"type": "refactor",
|
||||||
|
"status": "new",
|
||||||
|
"created_at": "2025-02-23T12:00:00Z",
|
||||||
|
"updated_at": "2025-02-23T12:00:00Z",
|
||||||
|
"description": "Refactor the meal tracking system to decouple 'Journal Logs' from 'Cookbook Recipes', resolving database pollution and improving system structure."
|
||||||
|
}
|
||||||
28
conductor/tracks/meal_tracker_refactor_20250223/plan.md
Normal file
28
conductor/tracks/meal_tracker_refactor_20250223/plan.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Implementation Plan - Meal Tracker Refactoring
|
||||||
|
|
||||||
|
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 [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 [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 [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 [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)
|
||||||
28
conductor/tracks/meal_tracker_refactor_20250223/spec.md
Normal file
28
conductor/tracks/meal_tracker_refactor_20250223/spec.md
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
# Specification - Meal Tracker Refactoring
|
||||||
|
|
||||||
|
**Overview:**
|
||||||
|
Refactor the meal tracking system to decouple "Journal Logs" from "Cookbook Recipes". Currently, adding a single food item via the tracker incorrectly creates a permanent 'Meal' record of type 'single_food', leading to database pollution and duplicate entries in the Meals library.
|
||||||
|
|
||||||
|
**Functional Requirements:**
|
||||||
|
- **TrackedMeal Schema Update:** Add a 'name' column to the 'TrackedMeal' model to store the display name of a logged meal or a single food item.
|
||||||
|
- **Nullable meal_id:** Modify 'TrackedMeal.meal_id' to be nullable, allowing "template-less" logs.
|
||||||
|
- **Refactored Tracker Logic:** Update the 'tracker_add_food' route to log single items directly as a 'TrackedMeal' with 'meal_id=NULL' and the 'name' set to the food item's name.
|
||||||
|
- **Nutrition Calculation:** Update nutrition calculation logic to handle 'TrackedMeal' entries without a parent 'Meal' template.
|
||||||
|
- **Tracker UI Update:** Ensure the tracker page displays 'TrackedMeal.name' for these logs and maintains the seamless visual style of existing entries.
|
||||||
|
- **Cookbook Cleanup (One-time Migration):** Migrate existing 'single_food' meals to the new format and purge the redundant records from the 'meals' and 'meal_foods' tables.
|
||||||
|
- **Cookbook Filtering:** Update the Meals page to exclude 'single_food' and 'snapshot' meal types from view.
|
||||||
|
|
||||||
|
**Non-Functional Requirements:**
|
||||||
|
- **Database Integrity:** Ensure all existing logs remain accurate and correctly linked to their food items during migration.
|
||||||
|
- **Performance:** The tracker page should remain fast and responsive with the new logic.
|
||||||
|
|
||||||
|
**Acceptance Criteria:**
|
||||||
|
- [ ] Adding a single food to the tracker does **not** create a new entry in the 'meals' table.
|
||||||
|
- [ ] Existing 'single_food' duplicates are removed from the 'meals' library.
|
||||||
|
- [ ] The Meals page only shows "Cookbook Recipes" (e.g., proper combined meals).
|
||||||
|
- [ ] The Tracker page correctly displays names and calculates nutrition for all logs (both template-based and template-less).
|
||||||
|
- [ ] "Save as New Meal" remains available for all log entries, including single foods.
|
||||||
|
|
||||||
|
**Out of Scope:**
|
||||||
|
- Refactoring the entire meal planning system beyond the tracker/cookbook separation.
|
||||||
|
- Changes to the external Open Food Facts integration.
|
||||||
59
foodplanner-dev.nomad
Normal file
59
foodplanner-dev.nomad
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
variable "container_version" {
|
||||||
|
default = "dev"
|
||||||
|
}
|
||||||
|
|
||||||
|
job "foodplanner-dev" {
|
||||||
|
datacenters = ["dc1"]
|
||||||
|
|
||||||
|
type = "service"
|
||||||
|
|
||||||
|
group "app" {
|
||||||
|
count = 1
|
||||||
|
|
||||||
|
network {
|
||||||
|
port "http" {
|
||||||
|
to = 8999
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
service {
|
||||||
|
name = "foodplanner-dev"
|
||||||
|
port = "http"
|
||||||
|
|
||||||
|
check {
|
||||||
|
type = "http"
|
||||||
|
path = "/"
|
||||||
|
interval = "10s"
|
||||||
|
timeout = "2s"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
task "app" {
|
||||||
|
driver = "docker"
|
||||||
|
|
||||||
|
config {
|
||||||
|
image = "ghcr.io/sstent/foodplanner:${var.container_version}"
|
||||||
|
ports = ["http"]
|
||||||
|
}
|
||||||
|
env {
|
||||||
|
DATABASE_URL = "postgresql://postgres:postgres@master.postgres.service.dc1.consul/meal_planner_dev"
|
||||||
|
|
||||||
|
}
|
||||||
|
resources {
|
||||||
|
cpu = 500
|
||||||
|
memory = 1024
|
||||||
|
}
|
||||||
|
|
||||||
|
# Restart policy
|
||||||
|
restart {
|
||||||
|
attempts = 3
|
||||||
|
interval = "10m"
|
||||||
|
delay = "15s"
|
||||||
|
mode = "fail"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,15 +70,15 @@
|
|||||||
{% for tracked_meal in meals_for_time %}
|
{% for tracked_meal in meals_for_time %}
|
||||||
{# 1. Create stable slugs #}
|
{# 1. Create stable slugs #}
|
||||||
{% set meal_time_slug = meal_time|slugify %}
|
{% 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 #}
|
{# 2. Construct the core Unique Meal ID for non-ambiguous locating #}
|
||||||
{% set unique_meal_id = meal_time_slug + '-' + meal_name_safe + '-' + loop.index|string %}
|
{% 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="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 class="d-flex justify-content-between align-items-center mb-2">
|
||||||
<div>
|
<div>
|
||||||
<strong data-testid="meal-name-{{ unique_meal_id }}">{{ tracked_meal.meal.name
|
<strong data-testid="meal-name-{{ unique_meal_id }}">{{ display_meal_name }}</strong>
|
||||||
}}</strong>
|
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<button class="btn btn-sm btn-outline-secondary me-1"
|
<button class="btn btn-sm btn-outline-secondary me-1"
|
||||||
@@ -126,6 +126,7 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{# Display base meal foods, applying overrides #}
|
{# Display base meal foods, applying overrides #}
|
||||||
|
{% if tracked_meal.meal %}
|
||||||
{% for meal_food in tracked_meal.meal.meal_foods %}
|
{% 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
|
{% if meal_food.food_id not in deleted_food_ids and meal_food.food_id not in
|
||||||
overrides.keys() %}
|
overrides.keys() %}
|
||||||
@@ -162,6 +163,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{# Display overridden/new foods #}
|
{# Display overridden/new foods #}
|
||||||
{% for food_id, tmf in overrides.items() %}
|
{% for food_id, tmf in overrides.items() %}
|
||||||
|
|||||||
@@ -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)()
|
query_session = sessionmaker(autocommit=False, autoflush=False, bind=test_engine)()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Find the created Meal
|
# Verify NO new Meal was created
|
||||||
created_meal = query_session.query(Meal).order_by(Meal.id.desc()).first()
|
meals = query_session.query(Meal).all()
|
||||||
assert created_meal is not None
|
assert len(meals) == 0
|
||||||
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}"
|
|
||||||
|
|
||||||
# Also verify TrackedDay and TrackedMeal were created
|
# Also verify TrackedDay and TrackedMeal were created
|
||||||
tracked_day = query_session.query(TrackedDay).filter(
|
tracked_day = query_session.query(TrackedDay).filter(
|
||||||
@@ -123,9 +112,17 @@ 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()
|
tracked_meal = query_session.query(TrackedMeal).filter(TrackedMeal.tracked_day_id == tracked_day.id).first()
|
||||||
assert tracked_meal is not None
|
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"
|
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:
|
finally:
|
||||||
query_session.close()
|
query_session.close()
|
||||||
|
|
||||||
|
|||||||
@@ -140,10 +140,13 @@ def test_tracker_add_food_grams_input(client, session, sample_food_100g):
|
|||||||
assert response.json()["status"] == "success"
|
assert response.json()["status"] == "success"
|
||||||
|
|
||||||
# Verify the tracked meal food quantity
|
# 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
|
assert tracked_meal is not None
|
||||||
meal_food = session.query(MealFood).filter(MealFood.meal_id == tracked_meal.id).first()
|
assert tracked_meal.meal_id is None
|
||||||
assert meal_food.quantity == grams
|
|
||||||
|
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):
|
def test_update_tracked_meal_foods_grams_input(client, session, sample_food_100g, sample_food_50g):
|
||||||
"""Test updating tracked meal foods with grams input"""
|
"""Test updating tracked meal foods with grams input"""
|
||||||
|
|||||||
113
tests/test_tracked_meal_refactor.py
Normal file
113
tests/test_tracked_meal_refactor.py
Normal 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
|
||||||
@@ -384,12 +384,13 @@ class TestTrackerAddFood:
|
|||||||
assert len(tracked_meals) == 1
|
assert len(tracked_meals) == 1
|
||||||
|
|
||||||
tracked_meal = tracked_meals[0]
|
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
|
# Verify the food is in the tracked meal's foods
|
||||||
assert len(tracked_meal.meal.meal_foods) == 1
|
assert len(tracked_meal.tracked_foods) == 1
|
||||||
assert tracked_meal.meal.meal_foods[0].food_id == sample_food.id
|
assert tracked_meal.tracked_foods[0].food_id == sample_food.id
|
||||||
assert tracked_meal.meal.meal_foods[0].quantity == 100.0
|
assert tracked_meal.tracked_foods[0].quantity == 100.0
|
||||||
|
|
||||||
|
|
||||||
def test_add_food_to_tracker_with_meal_time(self, client, sample_food, db_session):
|
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
|
assert len(tracked_meals) == 1
|
||||||
|
|
||||||
tracked_meal = tracked_meals[0]
|
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 len(tracked_meal.tracked_foods) == 1
|
||||||
assert tracked_meal.meal.meal_foods[0].food_id == sample_food.id
|
assert tracked_meal.tracked_foods[0].food_id == sample_food.id
|
||||||
assert tracked_meal.meal.meal_foods[0].quantity == 150.0
|
assert tracked_meal.tracked_foods[0].quantity == 150.0
|
||||||
|
|
||||||
def test_add_food_quantity_is_correctly_converted_to_servings(self, client, db_session):
|
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
|
assert len(tracked_meals) == 1
|
||||||
|
|
||||||
tracked_meal = tracked_meals[0]
|
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
|
# Verify the food is in the tracked meal's foods
|
||||||
assert len(tracked_meal.meal.meal_foods) == 1
|
assert len(tracked_meal.tracked_foods) == 1
|
||||||
assert tracked_meal.meal.meal_foods[0].food_id == food.id
|
assert tracked_meal.tracked_foods[0].food_id == food.id
|
||||||
assert tracked_meal.meal.meal_foods[0].quantity == grams_to_add
|
assert tracked_meal.tracked_foods[0].quantity == grams_to_add
|
||||||
|
|
||||||
# Verify nutrition calculation
|
# Verify nutrition calculation
|
||||||
day_nutrition = calculate_day_nutrition_tracked([tracked_meal], db_session)
|
day_nutrition = calculate_day_nutrition_tracked([tracked_meal], db_session)
|
||||||
|
|||||||
32
tests/tracked_meal_refactor.spec.js
Normal file
32
tests/tracked_meal_refactor.spec.js
Normal 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();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user