Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 6 additions & 11 deletions frontend/src/components/FoodSearchInput.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
17 changes: 10 additions & 7 deletions frontend/src/components/MealForm.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down
36 changes: 16 additions & 20 deletions frontend/src/components/PhotoMealUpload.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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
}]);
Expand Down Expand Up @@ -571,24 +565,27 @@ 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,
base_carbs_g: food.carbs_g,
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,
});

Expand Down Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/components/ReplaceFoodModal.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions frontend/src/components/__tests__/FoodSearchInput.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<FoodSearchInput onFoodSelect={mockOnSelect} />);

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(<FoodSearchInput onFoodSelect={mockOnSelect} />);
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,
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/components/__tests__/MealForm.extended.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,17 @@ vi.mock('../FoodSearchInput', () => ({
})}>
Select Test Food
</button>
<button onClick={() => onFoodSelect({
name: 'Test Food',
calories: 100,
protein_g: 10,
carbs_g: 15,
fat_g: 5,
fiber_g: 2,
quantity: 2
})}>
Select Double-Quantity Food
</button>
</div>
)
}));
Expand Down Expand Up @@ -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(
<BrowserRouter>
<MealForm onMealAdded={vi.fn()} />
</BrowserRouter>
);

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(
<BrowserRouter>
<MealForm onMealAdded={vi.fn()} />
</BrowserRouter>
);

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();
});
});
});
34 changes: 33 additions & 1 deletion frontend/src/components/__tests__/MealForm.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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 });
}
}} />
</div>
),
Expand Down Expand Up @@ -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();
});
});
});
Loading
Loading