mirror of
https://github.com/sstent/foodplanner.git
synced 2026-04-02 10:35:26 +00:00
Compare commits
15 Commits
8d3e91a825
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 1475190b90 | |||
| e0f72d4f1f | |||
| cc4b8301b3 | |||
| e68a1f52af | |||
| 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)
|
||||
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})
|
||||
|
||||
@@ -77,12 +77,15 @@ async def tracker_page(request: Request, person: str = "Sarah", date: str = None
|
||||
# Template will handle filtering of deleted foods
|
||||
# Get all meals for dropdown (exclude snapshots)
|
||||
meals = db.query(Meal).filter(Meal.meal_type != "tracked_snapshot").all()
|
||||
meals.sort(key=lambda x: x.name.lower())
|
||||
|
||||
# Get all templates for template dropdown
|
||||
templates_list = db.query(Template).all()
|
||||
templates_list.sort(key=lambda x: x.name.lower())
|
||||
|
||||
# Get all foods for dropdown
|
||||
foods = db.query(Food).all()
|
||||
foods.sort(key=lambda x: x.name.lower())
|
||||
|
||||
# Calculate day totals
|
||||
day_totals = calculate_day_nutrition_tracked(tracked_meals, db)
|
||||
@@ -746,23 +749,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -19,6 +19,7 @@ A performance-oriented meal planning application designed for health-conscious h
|
||||
- **Detailed Daily Planner:** A granular view of each day to visualize meal distribution and ensure macro balance across the day.
|
||||
- **Meals Library:** A centralized repository to create and save custom meals from individual food components.
|
||||
- **Template System:** Save and apply successful daily or weekly structures to future plans to minimize repetitive data entry.
|
||||
- **Smart Searching & Sorting:** Real-time, case-insensitive searching and alphabetical sorting in food and meal selection modals for low-friction data entry.
|
||||
- **Open Food Facts Integration:** Rapidly expand the local food database by importing data directly from the Open Food Facts API.
|
||||
|
||||
## Technical Philosophy
|
||||
|
||||
@@ -3,3 +3,11 @@
|
||||
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/)*
|
||||
|
||||
---
|
||||
|
||||
- [x] **Track: Add real-time search and alphabetical sorting to the Add Meal and Add Food modals on the tracker page.**
|
||||
*Link: [./tracks/meal_food_search_20260310/](./tracks/meal_food_search_20260310/)*
|
||||
|
||||
5
conductor/tracks/meal_food_search_20260310/index.md
Normal file
5
conductor/tracks/meal_food_search_20260310/index.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Track meal_food_search_20260310 Context
|
||||
|
||||
- [Specification](./spec.md)
|
||||
- [Implementation Plan](./plan.md)
|
||||
- [Metadata](./metadata.json)
|
||||
8
conductor/tracks/meal_food_search_20260310/metadata.json
Normal file
8
conductor/tracks/meal_food_search_20260310/metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"track_id": "meal_food_search_20260310",
|
||||
"type": "feature",
|
||||
"status": "new",
|
||||
"created_at": "2026-03-10T12:00:00Z",
|
||||
"updated_at": "2026-03-10T12:00:00Z",
|
||||
"description": "Add real-time search and alphabetical sorting to the Add Meal and Add Food modals on the tracker page."
|
||||
}
|
||||
32
conductor/tracks/meal_food_search_20260310/plan.md
Normal file
32
conductor/tracks/meal_food_search_20260310/plan.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Implementation Plan: Meal/Food Search & Sorting (Track: meal_food_search_20260310)
|
||||
|
||||
## Phase 1: Preparation and Testing
|
||||
- [x] Task: Create a new Playwright test file `tests/meal_food_search.spec.js` to verify searching and sorting in modals.
|
||||
- [x] Task: Write failing E2E tests for:
|
||||
- [x] Modal opening and initial alphabetical sorting of Meals.
|
||||
- [x] Real-time filtering in the "Add Meal" modal.
|
||||
- [x] Modal opening and initial alphabetical sorting of Foods.
|
||||
- [x] Real-time filtering (by name and brand) in the "Add Food" modal.
|
||||
- [x] Task: Run the tests and confirm they fail.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 1: Preparation and Testing' (Protocol in workflow.md)
|
||||
|
||||
## Phase 2: Implement Sorting & Searching in "Add Meal" Modal
|
||||
- [x] Task: Update `templates/modals/add_meal.html` to add a search input field above the meal list.
|
||||
- [x] Task: Add a unique `data-testid` to the search input and individual list items for test reliability.
|
||||
- [x] Task: Update the backend route `app/api/routes/tracker.py` to sort the `meals` list alphabetically before passing it to the template.
|
||||
- [x] Task: Implement client-side JavaScript in `templates/modals/add_meal.html` to filter the meal list in real-time as the user types.
|
||||
- [x] Task: Verify Phase 2 with E2E tests.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 2: Implement Sorting & Searching in Add Meal Modal' (Protocol in workflow.md)
|
||||
|
||||
## Phase 3: Implement Sorting & Searching in "Add Food" Modal
|
||||
- [x] Task: Update `templates/modals/add_single_food.html` (or the relevant "Add Food" modal) to add a search input field above the food list.
|
||||
- [x] Task: Add a unique `data-testid` to the search input and food list items.
|
||||
- [x] Task: Update the backend route `app/api/routes/tracker.py` to sort the `foods` list alphabetically before passing it to the template.
|
||||
- [x] Task: Implement client-side JavaScript in the "Add Food" modal to filter by both `name` and `brand` in real-time.
|
||||
- [x] Task: Verify Phase 3 with E2E tests.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 3: Implement Sorting & Searching in Add Food Modal' (Protocol in workflow.md)
|
||||
|
||||
## Phase 4: Final Verification and Cleanup
|
||||
- [x] Task: Perform a final run of all tests (E2E and Backend).
|
||||
- [x] Task: Ensure code coverage for any new logic (if applicable) is >80%.
|
||||
- [x] Task: Conductor - User Manual Verification 'Phase 4: Final Verification and Cleanup' (Protocol in workflow.md)
|
||||
32
conductor/tracks/meal_food_search_20260310/spec.md
Normal file
32
conductor/tracks/meal_food_search_20260310/spec.md
Normal file
@@ -0,0 +1,32 @@
|
||||
# Specification: Meal/Food Search & Sorting (Track: meal_food_search_20260310)
|
||||
|
||||
## Overview
|
||||
Enhance the user experience on the Tracker page by implementing real-time searching and alphabetical sorting for the "Add Meal" and "Add Food" modals. This will allow users to quickly locate specific items in their potentially large database of foods and meals.
|
||||
|
||||
## Functional Requirements
|
||||
1. **Alphabetical Sorting**:
|
||||
- The list of available meals in the "Add Meal" modal must be sorted alphabetically (A-Z) by name.
|
||||
- The list of available foods in the "Add Food" modal must be sorted alphabetically (A-Z) by name.
|
||||
2. **Real-time Search Filter**:
|
||||
- A search bar (text input) must be added above the lists in both "Add Meal" and "Add Food" modals.
|
||||
- As the user types in the search bar, the list must filter in real-time.
|
||||
- The filter should be case-insensitive.
|
||||
3. **Search Scope**:
|
||||
- For **Meals**: The search should match against the `name` field.
|
||||
- For **Foods**: The search should match against both the `name` and `brand` fields.
|
||||
|
||||
## Non-Functional Requirements
|
||||
- **Performance**: Filtering should be near-instantaneous on the client-side for a smooth user experience.
|
||||
- **Maintainability**: Use standard Bootstrap and Vanilla JavaScript patterns consistent with the existing codebase.
|
||||
|
||||
## Acceptance Criteria
|
||||
- [ ] Open the "Add Meal" modal on the Tracker page; meals are sorted A-Z.
|
||||
- [ ] Type in the search bar in the "Add Meal" modal; the list filters to show only matching meals.
|
||||
- [ ] Open the "Add Food" modal on the Tracker page; foods are sorted A-Z.
|
||||
- [ ] Type a food name or brand in the search bar in the "Add Food" modal; the list filters correctly.
|
||||
- [ ] Clearing the search bar restores the full (sorted) list.
|
||||
|
||||
## Out of Scope
|
||||
- Server-side searching (filtering will be done on the already-loaded client-side list).
|
||||
- Advanced fuzzy matching (initially, simple substring matching is sufficient).
|
||||
- Searching for other tabs like "Foods" or "Meals" (this track is specific to the Tracker page modals).
|
||||
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"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
@@ -13,10 +13,11 @@
|
||||
<input type="hidden" name="date" value="{{ current_date.isoformat() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Select Meal</label>
|
||||
<select class="form-control" name="meal_id" required>
|
||||
<input type="text" class="form-control mb-2" id="mealSearchInput" placeholder="Search meals..." data-testid="meal-search-input">
|
||||
<select class="form-control" name="meal_id" id="mealSelect" required size="10">
|
||||
<option value="">Choose meal...</option>
|
||||
{% for meal in meals %}
|
||||
<option value="{{ meal.id }}">{{ meal.name }}</option>
|
||||
<option value="{{ meal.id }}" data-testid="meal-option">{{ meal.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -28,4 +29,33 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.getElementById('mealSearchInput').addEventListener('input', function() {
|
||||
const searchText = this.value.toLowerCase();
|
||||
const select = document.getElementById('mealSelect');
|
||||
const options = select.options;
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
if (option.value === "") continue; // Skip "Choose meal..."
|
||||
|
||||
const text = option.text.toLowerCase();
|
||||
if (text.includes(searchText)) {
|
||||
option.style.display = "";
|
||||
} else {
|
||||
option.style.display = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset search when modal is shown
|
||||
document.getElementById('addMealModal').addEventListener('show.bs.modal', function () {
|
||||
document.getElementById('mealSearchInput').value = '';
|
||||
const options = document.getElementById('mealSelect').options;
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
options[i].style.display = "";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -12,10 +12,11 @@
|
||||
<input type="hidden" name="date" value="{{ current_date.isoformat() }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Select Food</label>
|
||||
<select class="form-control" name="food_id" required onchange="updateServingSizeNote(this)">
|
||||
<input type="text" class="form-control mb-2" id="foodSearchInput" placeholder="Search foods by name or brand..." data-testid="food-search-input">
|
||||
<select class="form-control" name="food_id" id="foodSelect" required onchange="updateServingSizeNote(this)" size="10">
|
||||
<option value="">Choose food...</option>
|
||||
{% for food in foods %}
|
||||
<option value="{{ food.id }}" data-serving-size="{{ food.serving_size }}">{{ food.name }}</option>
|
||||
<option value="{{ food.id }}" data-serving-size="{{ food.serving_size }}" data-brand="{{ food.brand }}" data-testid="food-option">{{ food.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
@@ -43,6 +44,36 @@
|
||||
servingSizeNote.textContent = '';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('foodSearchInput').addEventListener('input', function() {
|
||||
const searchText = this.value.toLowerCase();
|
||||
const select = document.getElementById('foodSelect');
|
||||
const options = select.options;
|
||||
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
const option = options[i];
|
||||
if (option.value === "") continue; // Skip "Choose food..."
|
||||
|
||||
const text = option.text.toLowerCase();
|
||||
const brand = (option.getAttribute('data-brand') || "").toLowerCase();
|
||||
|
||||
if (text.includes(searchText) || brand.includes(searchText)) {
|
||||
option.style.display = "";
|
||||
} else {
|
||||
option.style.display = "none";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Reset search when modal is shown
|
||||
document.getElementById('addSingleFoodModal').addEventListener('show.bs.modal', function () {
|
||||
document.getElementById('foodSearchInput').value = '';
|
||||
const options = document.getElementById('foodSelect').options;
|
||||
for (let i = 0; i < options.length; i++) {
|
||||
options[i].style.display = "";
|
||||
}
|
||||
document.getElementById('servingSizeNote').textContent = '';
|
||||
});
|
||||
</script>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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() %}
|
||||
|
||||
88
tests/meal_food_search.spec.js
Normal file
88
tests/meal_food_search.spec.js
Normal file
@@ -0,0 +1,88 @@
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test.describe('Meal and Food Search & Sorting', () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/tracker');
|
||||
// Ensure we're in a known state
|
||||
page.on('dialog', dialog => dialog.accept());
|
||||
const resetBtn = page.getByRole('button', { name: 'Reset Page' });
|
||||
if (await resetBtn.isVisible()) {
|
||||
await resetBtn.click();
|
||||
}
|
||||
});
|
||||
|
||||
test('Add Meal modal should be sorted alphabetically and searchable', async ({ page }) => {
|
||||
// Open Add Meal modal for Breakfast
|
||||
await page.locator('[data-testid="add-meal-breakfast"]').click();
|
||||
|
||||
// Check for search input
|
||||
const searchInput = page.locator('[data-testid="meal-search-input"]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// Check alphabetical sorting
|
||||
const mealOptions = page.locator('[data-testid="meal-option"]');
|
||||
const count = await mealOptions.count();
|
||||
let prevText = "";
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await mealOptions.nth(i).innerText();
|
||||
if (text.trim() === "" || text.includes("Choose meal...")) continue;
|
||||
|
||||
if (prevText !== "") {
|
||||
console.log(`Comparing: "${prevText}" to "${text}"`);
|
||||
const cmp = text.localeCompare(prevText, 'en', { sensitivity: 'base' });
|
||||
if (cmp < 0) {
|
||||
console.error(`Sort order failure: "${prevText}" should come after "${text}" (cmp: ${cmp})`);
|
||||
}
|
||||
expect(cmp).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
prevText = text;
|
||||
}
|
||||
|
||||
// Test real-time filtering
|
||||
await searchInput.fill('Protein');
|
||||
const filteredOptions = page.locator('[data-testid="meal-option"]:visible');
|
||||
const filteredCount = await filteredOptions.count();
|
||||
for (let i = 0; i < filteredCount; i++) {
|
||||
const text = await filteredOptions.nth(i).innerText();
|
||||
expect(text.toLowerCase()).toContain('protein');
|
||||
}
|
||||
});
|
||||
|
||||
test('Add Food modal should be sorted alphabetically and searchable', async ({ page }) => {
|
||||
// Open Add Food modal for Breakfast
|
||||
await page.locator('[data-testid="add-food-breakfast"]').click();
|
||||
|
||||
// Check for search input
|
||||
const searchInput = page.locator('[data-testid="food-search-input"]');
|
||||
await expect(searchInput).toBeVisible();
|
||||
|
||||
// Check alphabetical sorting
|
||||
const foodOptions = page.locator('[data-testid="food-option"]');
|
||||
const count = await foodOptions.count();
|
||||
let prevText = "";
|
||||
for (let i = 0; i < count; i++) {
|
||||
const text = await foodOptions.nth(i).innerText();
|
||||
if (text.trim() === "" || text.includes("Choose food...")) continue;
|
||||
|
||||
if (prevText !== "") {
|
||||
console.log(`Comparing: "${prevText}" to "${text}"`);
|
||||
const cmp = text.localeCompare(prevText, 'en', { sensitivity: 'base' });
|
||||
if (cmp < 0) {
|
||||
console.error(`Sort order failure: "${prevText}" should come after "${text}" (cmp: ${cmp})`);
|
||||
}
|
||||
expect(cmp).toBeGreaterThanOrEqual(0);
|
||||
}
|
||||
prevText = text;
|
||||
}
|
||||
|
||||
// Test real-time filtering
|
||||
await searchInput.fill('Organic');
|
||||
const filteredOptions = page.locator('[data-testid="food-option"]:visible');
|
||||
const filteredCount = await filteredOptions.count();
|
||||
for (let i = 0; i < filteredCount; i++) {
|
||||
const text = (await filteredOptions.nth(i).innerText()).toLowerCase();
|
||||
const brand = (await filteredOptions.nth(i).getAttribute('data-brand') || "").toLowerCase();
|
||||
expect(text.includes('organic') || brand.includes('organic')).toBeTruthy();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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"""
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
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