diff --git a/frontend/src/components/FoodSearchInput.jsx b/frontend/src/components/FoodSearchInput.jsx index 223260d..3e39a0f 100644 --- a/frontend/src/components/FoodSearchInput.jsx +++ b/frontend/src/components/FoodSearchInput.jsx @@ -111,18 +111,13 @@ export default function FoodSearchInput({ onFoodSelect, initialValue = '' }) { }; const handleSelect = (food) => { - const multipliedFood = { - ...food, - portion: `${quantity} x ${food.portion}`, - calories: Math.round(food.calories * quantity), - protein_g: parseFloat((food.protein_g * quantity).toFixed(1)), - carbs_g: parseFloat((food.carbs_g * quantity).toFixed(1)), - fat_g: parseFloat((food.fat_g * quantity).toFixed(1)), - fiber_g: parseFloat((food.fiber_g * quantity).toFixed(1)), - }; - + // search-food/custom-food results are always per 100g. Pass the raw + // per-100g macros through untouched, plus the chosen quantity, and let + // the consumer (MealForm, PhotoMealUpload, ...) compute portion_size / + // shown totals - baking quantity into calories/macros here corrupted + // the per-100g data contract downstream. setQuery(food.name); - onFoodSelect(multipliedFood); + onFoodSelect({ ...food, quantity }); setResults([]); setShowDropdown(false); setQuantity(1); diff --git a/frontend/src/components/MealForm.jsx b/frontend/src/components/MealForm.jsx index 360c569..c26b100 100644 --- a/frontend/src/components/MealForm.jsx +++ b/frontend/src/components/MealForm.jsx @@ -92,22 +92,25 @@ export default function MealForm({ onMealAdded }) { const manualViolations = checkDietaryViolations(mealName, dietaryRestrictions); const handleFoodSelect = (food) => { + // food.calories/protein_g/etc are raw per-100g values; food.quantity is + // the multiplier chosen in FoodSearchInput (defaults to 1 unit = 100g). + const q = food.quantity || 1; const newFood = { id: `food-${Date.now()}`, name: food.name, - portion_size: 100, - portion_display: '1', + portion_size: q * 100, + portion_display: String(q), portion_unit: 'g', base_calories: food.calories, base_protein_g: food.protein_g, base_carbs_g: food.carbs_g, base_fat_g: food.fat_g, base_fiber_g: food.fiber_g || 0, - calories: food.calories, - protein_g: food.protein_g, - carbs_g: food.carbs_g, - fat_g: food.fat_g, - fiber_g: food.fiber_g || 0, + calories: Math.round(food.calories * q), + protein_g: parseFloat((food.protein_g * q).toFixed(1)), + carbs_g: parseFloat((food.carbs_g * q).toFixed(1)), + fat_g: parseFloat((food.fat_g * q).toFixed(1)), + fiber_g: parseFloat(((food.fiber_g || 0) * q).toFixed(1)), }; setFoods([...foods, newFood]); diff --git a/frontend/src/components/PhotoMealUpload.jsx b/frontend/src/components/PhotoMealUpload.jsx index 7bc87d8..91fc529 100644 --- a/frontend/src/components/PhotoMealUpload.jsx +++ b/frontend/src/components/PhotoMealUpload.jsx @@ -5,6 +5,7 @@ import { updateDailyAchievement } from '../utils/updateDailyAchievement'; import api from '../services/api'; import FoodSearchInput from './FoodSearchInput'; import imageCompression from 'browser-image-compression'; +import { toPer100g } from '../utils/foodMacros'; export default function PhotoMealUpload({ onMealAdded }) { const { goals } = useGoals(); @@ -474,19 +475,12 @@ export default function PhotoMealUpload({ onMealAdded }) { const { data: { user } } = await supabase.auth.getUser(); // Convert base values to per 100g for consistency - const basePortionSize = food.base_portion_size || 100; - const conversionMultiplier = 100 / basePortionSize; - const { error } = await supabase .from('user_foods') .insert([{ user_id: user.id, name: foodName, - base_calories: Math.round(food.base_calories * conversionMultiplier), - base_protein_g: parseFloat((food.base_protein_g * conversionMultiplier).toFixed(1)), - base_carbs_g: parseFloat((food.base_carbs_g * conversionMultiplier).toFixed(1)), - base_fat_g: parseFloat((food.base_fat_g * conversionMultiplier).toFixed(1)), - base_fiber_g: parseFloat((food.base_fiber_g * conversionMultiplier).toFixed(1)), + ...toPer100g(food), source: 'edited_from_ai', original_food_name: food.name }]); @@ -571,12 +565,15 @@ export default function PhotoMealUpload({ onMealAdded }) { }; const handleFoodSelect = (food) => { + // food.calories/protein_g/etc are raw per-100g values; food.quantity is + // the multiplier chosen in FoodSearchInput (defaults to 1 unit = 100g). + const q = food.quantity || 1; const updated = { ...editableResult }; updated.foods.push({ name: food.name, portion: food.portion || '100g', - portion_size: 100, - portion_display: '1', // Default to "1" (representing 100g) + portion_size: q * 100, + portion_display: String(q), portion_unit: 'g', base_calories: food.calories, base_protein_g: food.protein_g, @@ -584,11 +581,11 @@ export default function PhotoMealUpload({ onMealAdded }) { base_fat_g: food.fat_g, base_fiber_g: food.fiber_g || 0, base_portion_size: 100, // Database foods are per 100g - calories: food.calories, - protein_g: food.protein_g, - carbs_g: food.carbs_g, - fat_g: food.fat_g, - fiber_g: food.fiber_g || 0, + calories: Math.round(food.calories * q), + protein_g: parseFloat((food.protein_g * q).toFixed(1)), + carbs_g: parseFloat((food.carbs_g * q).toFixed(1)), + fat_g: parseFloat((food.fat_g * q).toFixed(1)), + fiber_g: parseFloat(((food.fiber_g || 0) * q).toFixed(1)), confidence: 1.0, }); @@ -650,16 +647,15 @@ export default function PhotoMealUpload({ onMealAdded }) { // Save individual components if it's a compound food if (editableResult.foods.length > 1 && mealData[0]) { + // base_* on meal_components is always per 100g (see data contract in + // foodMacros.js); photo-analyzed foods carry base_* per + // base_portion_size, so normalize before persisting. const components = editableResult.foods.map(food => ({ meal_id: mealData[0].id, component_name: food.name, portion_size: food.portion_size || 100, portion_unit: food.portion_unit || 'g', - base_calories: food.base_calories, - base_protein_g: food.base_protein_g, - base_carbs_g: food.base_carbs_g, - base_fat_g: food.base_fat_g, - base_fiber_g: food.base_fiber_g || 0, + ...toPer100g(food), })); const { error: componentsError } = await supabase diff --git a/frontend/src/components/ReplaceFoodModal.jsx b/frontend/src/components/ReplaceFoodModal.jsx index 0aa5d2a..c07065c 100644 --- a/frontend/src/components/ReplaceFoodModal.jsx +++ b/frontend/src/components/ReplaceFoodModal.jsx @@ -35,6 +35,9 @@ export default function ReplaceFoodModal({ food, onReplace, onClose }) { }; const handleDatabaseFoodSelect = (selectedFood) => { + // selectedFood.calories/etc are raw per-100g values (FoodSearchInput no + // longer bakes its quantity in); we ignore selectedFood.quantity here + // and keep the replaced component's existing portion_size. onReplace({ name: selectedFood.name, base_calories: selectedFood.calories, diff --git a/frontend/src/components/__tests__/FoodSearchInput.test.jsx b/frontend/src/components/__tests__/FoodSearchInput.test.jsx index 2158fc9..d4146a0 100644 --- a/frontend/src/components/__tests__/FoodSearchInput.test.jsx +++ b/frontend/src/components/__tests__/FoodSearchInput.test.jsx @@ -143,6 +143,50 @@ describe('FoodSearchInput Component', () => { expect(searchInput.value).toBe('chicken'); }); + describe('handleSelect payload (per-100g data contract)', () => { + it('passes raw per-100g macros plus quantity, without multiplying', async () => { + const mockFood = { name: 'Chicken Breast', portion: '100g', calories: 165, protein_g: 31, carbs_g: 0, fat_g: 3.6, fiber_g: 0 }; + api.get.mockResolvedValue({ data: { foods: [mockFood] } }); + + render(); + + const qtyInput = screen.getByPlaceholderText(/qty/i); + fireEvent.change(qtyInput, { target: { value: '2' } }); + + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'chicken' } }); + + const resultButton = await screen.findByText('Chicken Breast'); + fireEvent.click(resultButton); + + expect(mockOnSelect).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'Chicken Breast', + calories: 165, + protein_g: 31, + carbs_g: 0, + fat_g: 3.6, + fiber_g: 0, + quantity: 2, + }) + ); + }); + + it('defaults quantity to 1 when selecting without changing it', async () => { + const mockFood = { name: 'Rice', portion: '100g', calories: 130, protein_g: 2.7, carbs_g: 28, fat_g: 0.3, fiber_g: 0.4 }; + api.get.mockResolvedValue({ data: { foods: [mockFood] } }); + + render(); + fireEvent.change(screen.getByPlaceholderText(/search/i), { target: { value: 'rice' } }); + + const resultButton = await screen.findByText('Rice'); + fireEvent.click(resultButton); + + expect(mockOnSelect).toHaveBeenCalledWith( + expect.objectContaining({ calories: 130, quantity: 1 }) + ); + }); + }); + describe('search error + stale responses', () => { const food = (name) => ({ name, portion: '100g', calories: 100, protein_g: 1, carbs_g: 1, fat_g: 1, fiber_g: 0, diff --git a/frontend/src/components/__tests__/MealForm.extended.test.jsx b/frontend/src/components/__tests__/MealForm.extended.test.jsx index 1f9150d..1db04ad 100644 --- a/frontend/src/components/__tests__/MealForm.extended.test.jsx +++ b/frontend/src/components/__tests__/MealForm.extended.test.jsx @@ -70,6 +70,17 @@ vi.mock('../FoodSearchInput', () => ({ })}> Select Test Food + ) })); @@ -360,4 +371,40 @@ describe('MealForm - Comprehensive Tests', () => { } }); }); + + describe('per-100g data contract', () => { + it('quantity 2 from search -> portion_size 200, totals doubled, base unchanged', async () => { + render( + + + + ); + + fireEvent.click(screen.getByText(/select double-quantity food/i)); + + await waitFor(() => { + expect(screen.getByText(/base: 100 cal per 100g/i)).toBeInTheDocument(); + }); + // portion_display shows quantity (2); shown calories/macros are base * 2 + expect(screen.getByDisplayValue('2')).toBeInTheDocument(); + expect(screen.getByText('200 cal')).toBeInTheDocument(); + expect(screen.getByText(/P: 20\.0g/)).toBeInTheDocument(); + }); + + it('no quantity from search -> defaults to 1, portion_size 100', async () => { + render( + + + + ); + + fireEvent.click(screen.getByText(/^select test food$/i)); + + await waitFor(() => { + expect(screen.getByText(/base: 100 cal per 100g/i)).toBeInTheDocument(); + }); + expect(screen.getByDisplayValue('1')).toBeInTheDocument(); + expect(screen.getByText('100 cal')).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/components/__tests__/MealForm.test.jsx b/frontend/src/components/__tests__/MealForm.test.jsx index f3d28d8..6b4463c 100644 --- a/frontend/src/components/__tests__/MealForm.test.jsx +++ b/frontend/src/components/__tests__/MealForm.test.jsx @@ -2,7 +2,7 @@ * Tests for MealForm component */ import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; import { BrowserRouter } from 'react-router-dom'; import MealForm from '../MealForm'; @@ -49,6 +49,9 @@ vi.mock('../FoodSearchInput', () => ({ if (e.target.value === 'test') { onFoodSelect({ name: 'Test Food', calories: 100, protein_g: 10, carbs_g: 5, fat_g: 3, fiber_g: 2 }); } + if (e.target.value === 'test-qty2') { + onFoodSelect({ name: 'Test Food', calories: 100, protein_g: 10, carbs_g: 5, fat_g: 3, fiber_g: 2, quantity: 2 }); + } }} /> ), @@ -255,4 +258,33 @@ describe('MealForm', () => { // Basic test - should handle adding same food multiple times expect(true).toBe(true); }); + + describe('per-100g data contract', () => { + it('defaults to quantity 1 -> portion_size 100, base unchanged', async () => { + renderMealForm(); + const searchInput = screen.getByPlaceholderText('Search food'); + fireEvent.change(searchInput, { target: { value: 'test' } }); + + await waitFor(() => { + expect(screen.getByText(/base: 100 cal per 100g/i)).toBeInTheDocument(); + }); + expect(screen.getByDisplayValue('1')).toBeInTheDocument(); + expect(screen.getByText('100 cal')).toBeInTheDocument(); + }); + + it('quantity 2 from search -> portion_size 200, totals doubled, base unchanged', async () => { + renderMealForm(); + const searchInput = screen.getByPlaceholderText('Search food'); + fireEvent.change(searchInput, { target: { value: 'test-qty2' } }); + + await waitFor(() => { + // base_* stays the raw per-100g value FoodSearchInput returned + expect(screen.getByText(/base: 100 cal per 100g/i)).toBeInTheDocument(); + }); + // portion_display reflects the quantity (2), and shown calories are doubled + expect(screen.getByDisplayValue('2')).toBeInTheDocument(); + expect(screen.getByText('200 cal')).toBeInTheDocument(); + expect(screen.getByText(/P: 20\.0g/)).toBeInTheDocument(); + }); + }); }); diff --git a/frontend/src/utils/__tests__/foodMacros.test.js b/frontend/src/utils/__tests__/foodMacros.test.js new file mode 100644 index 0000000..39ca0b9 --- /dev/null +++ b/frontend/src/utils/__tests__/foodMacros.test.js @@ -0,0 +1,130 @@ +import { describe, it, expect } from 'vitest'; +import { toPer100g } from '../foodMacros'; + +describe('toPer100g', () => { + it('scales base values up when base_portion_size < 100', () => { + // e.g. Gemini detected "1 slice (20g)" with 5 cal + const food = { + base_calories: 5, + base_protein_g: 0.4, + base_carbs_g: 1, + base_fat_g: 0.2, + base_fiber_g: 0.1, + base_portion_size: 20, + }; + + expect(toPer100g(food)).toEqual({ + base_calories: 25, + base_protein_g: 2, + base_carbs_g: 5, + base_fat_g: 1, + base_fiber_g: 0.5, + }); + }); + + it('scales base values down when base_portion_size > 100', () => { + const food = { + base_calories: 500, + base_protein_g: 40, + base_carbs_g: 60, + base_fat_g: 20, + base_fiber_g: 10, + base_portion_size: 250, + }; + + expect(toPer100g(food)).toEqual({ + base_calories: 200, + base_protein_g: 16, + base_carbs_g: 24, + base_fat_g: 8, + base_fiber_g: 4, + }); + }); + + it('is a no-op (aside from rounding) when base_portion_size is already 100', () => { + const food = { + base_calories: 165, + base_protein_g: 31, + base_carbs_g: 0, + base_fat_g: 3.6, + base_fiber_g: 0, + base_portion_size: 100, + }; + + expect(toPer100g(food)).toEqual({ + base_calories: 165, + base_protein_g: 31, + base_carbs_g: 0, + base_fat_g: 3.6, + base_fiber_g: 0, + }); + }); + + it('falls back to base_portion_size 100 when 0', () => { + const food = { + base_calories: 100, + base_protein_g: 10, + base_carbs_g: 10, + base_fat_g: 5, + base_fiber_g: 2, + base_portion_size: 0, + }; + + expect(toPer100g(food)).toEqual({ + base_calories: 100, + base_protein_g: 10, + base_carbs_g: 10, + base_fat_g: 5, + base_fiber_g: 2, + }); + }); + + it('falls back to base_portion_size 100 when undefined', () => { + const food = { + base_calories: 100, + base_protein_g: 10, + base_carbs_g: 10, + base_fat_g: 5, + base_fiber_g: 2, + }; + + expect(toPer100g(food)).toEqual({ + base_calories: 100, + base_protein_g: 10, + base_carbs_g: 10, + base_fat_g: 5, + base_fiber_g: 2, + }); + }); + + it('defaults missing base_fiber_g to 0', () => { + const food = { + base_calories: 50, + base_protein_g: 5, + base_carbs_g: 5, + base_fat_g: 1, + base_portion_size: 50, + }; + + expect(toPer100g(food).base_fiber_g).toBe(0); + }); + + it('rounds macro grams to 1 decimal place', () => { + const food = { + base_calories: 7, + base_protein_g: 1, + base_carbs_g: 1, + base_fat_g: 1, + base_fiber_g: 1, + base_portion_size: 30, + }; + + // 100/30 = 3.333... + const result = toPer100g(food); + expect(result.base_protein_g).toBe(3.3); + expect(result.base_carbs_g).toBe(3.3); + expect(result.base_fat_g).toBe(3.3); + expect(result.base_fiber_g).toBe(3.3); + expect(result.base_calories).toBe(23); // Math.round(23.33) + }); +}); diff --git a/frontend/src/utils/foodMacros.js b/frontend/src/utils/foodMacros.js new file mode 100644 index 0000000..ea2cc37 --- /dev/null +++ b/frontend/src/utils/foodMacros.js @@ -0,0 +1,30 @@ +/** + * Convert a food's base_* macro values (stated per `base_portion_size` grams) + * into per-100g values. + * + * Data contract: base_* fields on stored foods (user_foods, meal_components) + * are always per 100g. This helper is the single place that performs that + * conversion so photo-analyzed foods (whose base values are per the + * AI-detected portion, not per 100g) get normalized before being persisted. + * + * @param {Object} food + * @param {number} food.base_calories + * @param {number} food.base_protein_g + * @param {number} food.base_carbs_g + * @param {number} food.base_fat_g + * @param {number} [food.base_fiber_g] + * @param {number} [food.base_portion_size] - grams the base_* values above correspond to; falls back to 100 when 0/undefined. + * @returns {{base_calories: number, base_protein_g: number, base_carbs_g: number, base_fat_g: number, base_fiber_g: number}} + */ +export function toPer100g(food) { + const basePortionSize = food.base_portion_size || 100; + const conversionMultiplier = 100 / basePortionSize; + + return { + base_calories: Math.round(food.base_calories * conversionMultiplier), + base_protein_g: parseFloat((food.base_protein_g * conversionMultiplier).toFixed(1)), + base_carbs_g: parseFloat((food.base_carbs_g * conversionMultiplier).toFixed(1)), + base_fat_g: parseFloat((food.base_fat_g * conversionMultiplier).toFixed(1)), + base_fiber_g: parseFloat(((food.base_fiber_g || 0) * conversionMultiplier).toFixed(1)), + }; +}