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)),
+ };
+}