3 Commits
main ... dev

10 changed files with 240 additions and 5 deletions

View File

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

View File

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

View File

@@ -6,3 +6,8 @@ This file tracks all major tracks for the project. Each track has its own detail
- [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/)*

View File

@@ -0,0 +1,5 @@
# Track meal_food_search_20260310 Context
- [Specification](./spec.md)
- [Implementation Plan](./plan.md)
- [Metadata](./metadata.json)

View 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."
}

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

View 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).

View File

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

View File

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

View 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();
}
});
});