diff --git a/frontend/src/components/CombinedNutrientFilter.tsx b/frontend/src/components/CombinedNutrientFilter.tsx new file mode 100644 index 00000000..e0f7f033 --- /dev/null +++ b/frontend/src/components/CombinedNutrientFilter.tsx @@ -0,0 +1,59 @@ +import { useState, useEffect } from 'react'; +import { apiClient } from '../lib/apiClient'; +import { NutrientFilter, NutrientFilterItem, buildNutrientQuery } from './NutrientFilter'; + +export interface CombinedNutrientFilterItem extends NutrientFilterItem {} + +interface CombinedNutrientFilterProps { + filters: CombinedNutrientFilterItem[]; + onChange: (filters: CombinedNutrientFilterItem[]) => void; +} + +interface AvailableNutrient { + name: string; + unit: string; +} + +const MACRONUTRIENTS: AvailableNutrient[] = [ + { name: 'Protein', unit: 'g' }, + { name: 'Carbohydrates', unit: 'g' }, + { name: 'Fat', unit: 'g' } +]; + +export const CombinedNutrientFilter = ({ filters, onChange }: CombinedNutrientFilterProps) => { + const [availableMicronutrients, setAvailableMicronutrients] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchMicronutrients = async () => { + try { + const response = await apiClient.getAvailableMicronutrients(); + setAvailableMicronutrients(response.micronutrients); + } catch (error) { + console.error('Error fetching micronutrients:', error); + } finally { + setLoading(false); + } + }; + + fetchMicronutrients(); + }, []); + + // Combine macro and micro nutrients + const allNutrients = [...MACRONUTRIENTS, ...availableMicronutrients]; + + return ( + + ); +}; + +export const buildCombinedNutrientQuery = buildNutrientQuery; + diff --git a/frontend/src/components/MacronutrientFilter.tsx b/frontend/src/components/MacronutrientFilter.tsx index 9d87859d..80f683a0 100644 --- a/frontend/src/components/MacronutrientFilter.tsx +++ b/frontend/src/components/MacronutrientFilter.tsx @@ -22,7 +22,7 @@ export const MacronutrientFilter = ({ filters, onChange }: MacronutrientFilterPr return ( -

{title}

+

{title}

- {!loading && availableNutrients.length > 0 && ( -

+ {description && !loading && availableNutrients.length > 0 && ( +

{description}

)} {/* Add new filter */} -
+
{/* Suggestions dropdown */} {showSuggestions && filteredSuggestions.length > 0 && (
{filteredSuggestions.map((nutrient, index) => ( @@ -167,8 +172,12 @@ export const NutrientFilter = ({ value={newFilterMin} onChange={(e) => setNewFilterMin(e.target.value)} onKeyPress={handleKeyPress} - className="w-1/2 px-3 py-2 border rounded-lg focus:ring-primary focus:border-primary nh-forum-search" - style={{ minWidth: 0 }} + className="w-1/2 px-4 py-2.5 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary nh-forum-search transition-all" + style={{ + minWidth: 0, + backgroundColor: 'var(--color-bg-secondary)', + borderColor: 'var(--dietary-option-border)' + }} /> setNewFilterMax(e.target.value)} onKeyPress={handleKeyPress} - className="w-1/2 px-3 py-2 border rounded-lg focus:ring-primary focus:border-primary nh-forum-search" - style={{ minWidth: 0 }} + className="w-1/2 px-4 py-2.5 border rounded-lg focus:ring-2 focus:ring-primary focus:border-primary nh-forum-search transition-all" + style={{ + minWidth: 0, + backgroundColor: 'var(--color-bg-secondary)', + borderColor: 'var(--dietary-option-border)' + }} />
{/* Current filters */} {filters.length > 0 && ( -
-

Active Filters:

+
+

+ Active Filters +

{filters.map((filter, index) => { const nutrient = availableNutrients.find( n => n.name.toLowerCase() === filter.name.toLowerCase() @@ -213,43 +228,39 @@ export const NutrientFilter = ({ return (
-

{filter.name}

-

{valueText}

+

{filter.name}

+

{valueText}

); })}
)} - {filters.length === 0 && ( -

- No active filters -

- )}
); }; diff --git a/frontend/src/pages/foods/FoodDetail.tsx b/frontend/src/pages/foods/FoodDetail.tsx index b393b847..7fbd2c0f 100644 --- a/frontend/src/pages/foods/FoodDetail.tsx +++ b/frontend/src/pages/foods/FoodDetail.tsx @@ -15,6 +15,8 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions }) const [selectedNutrient, setSelectedNutrient] = useState(null); const [customGrams, setCustomGrams] = useState(0); const [selectedServingSize, setSelectedServingSize] = useState(0); // Main serving size selector + const [servingUnit, setServingUnit] = useState<'g' | 'serving'>('g'); // Unit for serving size input + const [modalServingUnit, setModalServingUnit] = useState<'g' | 'serving'>('g'); // Unit for modal serving size input const [recommendations, setRecommendations] = useState<{ calories: number; protein: number; @@ -46,6 +48,8 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions }) useEffect(() => { if (food) { setSelectedServingSize(food.servingSize); + setServingUnit('g'); + setModalServingUnit('g'); } }, [food]); @@ -85,6 +89,7 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions }) e.preventDefault(); setSelectedNutrient(nutrient); setCustomGrams(selectedServingSize || food?.servingSize || 100); // Initialize with selected serving size + setModalServingUnit('g'); // Reset to grams when opening modal setShowRecommendations(true); }; @@ -216,7 +221,12 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions }) // Use custom grams if provided, otherwise use serving size const servingGrams = grams !== undefined && grams > 0 ? grams : food.servingSize; - const multiplier = servingGrams / food.servingSize; + + // Macronutrients are per serving, micronutrients are per 100g + const isMacronutrient = ['calories', 'protein', 'fat', 'carbs'].includes(nutrient); + const multiplier = isMacronutrient + ? servingGrams / food.servingSize // Scale by serving size for macros + : servingGrams / 100; // Scale by 100g for micros (already per 100g) switch (nutrient) { case 'calories': @@ -570,15 +580,22 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions })
{ const val = e.target.value; if (val === '') { setSelectedServingSize(0); } else { - setSelectedServingSize(Math.max(0, parseInt(val) || 0)); + const numVal = parseFloat(val) || 0; + if (servingUnit === 'g') { + setSelectedServingSize(Math.max(0, numVal)); + } else { + setSelectedServingSize(Math.max(0, numVal * (food?.servingSize || 100))); + } } }} onBlur={() => { @@ -586,40 +603,28 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions }) setSelectedServingSize(food?.servingSize || 100); } }} - className="w-20 px-2 py-1 rounded border text-sm font-semibold" + className="flex-1 px-3 py-2 rounded border text-sm font-semibold" style={{ backgroundColor: 'var(--color-bg-primary)', color: 'var(--color-text-primary)', borderColor: 'var(--color-border)', }} /> - g -
- - - - -
+
@@ -746,7 +751,8 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions }) // Remove only the unit part (last parentheses) from the name const nutrientName = nutrient.replace(/\s*\([^)]*\)\s*$/, '').trim(); // Calculate scaled amount based on selected serving size - const scaledAmount = (amount * selectedServingSize / food.servingSize); + // Micronutrients are already per 100g, so scale by selectedServingSize / 100 + const scaledAmount = (amount * selectedServingSize / 100); const exceedsMax = wouldExceedMaximum(nutrient); const exceedsTarget = wouldExceedTarget(nutrient); @@ -898,26 +904,35 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions }) ); })()} - {/* Custom Gram Input */} + {/* Custom Serving Size Input */}
{ const val = e.target.value; if (val === '') { setCustomGrams(0); setSelectedServingSize(0); } else { - const newValue = Math.max(0, parseInt(val) || 0); - setCustomGrams(newValue); - setSelectedServingSize(newValue); + const numVal = parseFloat(val) || 0; + if (modalServingUnit === 'g') { + const newValue = Math.max(0, numVal); + setCustomGrams(newValue); + setSelectedServingSize(newValue); + } else { + const newValue = Math.max(0, numVal * (food?.servingSize || 100)); + setCustomGrams(newValue); + setSelectedServingSize(newValue); + } } }} onBlur={() => { @@ -934,48 +949,21 @@ const FoodDetail: React.FC = ({ food, open, onClose, actions }) borderColor: 'var(--color-border)', }} /> - grams -
-
- - -
diff --git a/frontend/src/pages/foods/Foods.tsx b/frontend/src/pages/foods/Foods.tsx index c8708f67..1208da31 100644 --- a/frontend/src/pages/foods/Foods.tsx +++ b/frontend/src/pages/foods/Foods.tsx @@ -4,8 +4,7 @@ import { useState, useEffect } from 'react'; import { Link } from 'react-router-dom'; import FoodDetail from './FoodDetail'; import NutritionScore from '../../components/NutritionScore'; -import { MicronutrientFilter, MicronutrientFilterItem, buildMicronutrientQuery } from '../../components/MicronutrientFilter'; -import { MacronutrientFilter, MacronutrientFilterItem, buildMacronutrientQuery } from '../../components/MacronutrientFilter'; +import { CombinedNutrientFilter, CombinedNutrientFilterItem, buildCombinedNutrientQuery } from '../../components/CombinedNutrientFilter'; export const FoodItem = ({ item, onClick }: { item: Food, onClick: () => void }) => { return ( @@ -65,8 +64,7 @@ const SORT_OPTIONS = [ { key: 'nutritionscore', label: 'By Nutrition Score' }, { key: 'carbohydratecontent', label: 'By Carb Content' }, { key: 'proteincontent', label: 'By Protein Content' }, - { key: 'fatcontent', label: 'By Fat Content' }, - { key: '', label: 'Remove Sort' } + { key: 'fatcontent', label: 'By Fat Content' } ]; const Foods = () => { @@ -83,8 +81,7 @@ const Foods = () => { const [warning, setWarning] = useState(null); const [sortBy, setSortBy] = useState(''); const [sortOrder, setSortOrder] = useState<'desc' | 'asc'>('desc'); - const [micronutrientFilters, setMicronutrientFilters] = useState([]); - const [macronutrientFilters, setMacronutrientFilters] = useState([]); + const [nutrientFilters, setNutrientFilters] = useState([]); const [pageSize, setPageSize] = useState(null); const updatePageSize = (resultsLength: number, hasNext: boolean) => { @@ -100,11 +97,16 @@ const Foods = () => { }); }; - const fetchFoods = async (pageNum = 1, search = '', sortByParam = sortBy, sortOrderParam = sortOrder, microFilters = micronutrientFilters, macroFilters = macronutrientFilters) => { + const fetchFoods = async (pageNum = 1, search = '', sortByParam = sortBy, sortOrderParam = sortOrder, filters = nutrientFilters) => { setLoading(true); try { - const micronutrientQuery = microFilters.length > 0 ? buildMicronutrientQuery(microFilters) : undefined; - const macronutrientQuery = macroFilters.length > 0 ? buildMacronutrientQuery(macroFilters) : undefined; + // Separate micro and macro filters (case-insensitive) + const macroNames = ['protein', 'carbohydrates', 'fat']; + const microFilters = filters.filter(f => !macroNames.includes(f.name.toLowerCase())); + const macroFilters = filters.filter(f => macroNames.includes(f.name.toLowerCase())); + + const micronutrientQuery = microFilters.length > 0 ? buildCombinedNutrientQuery(microFilters) : undefined; + const macronutrientQuery = macroFilters.length > 0 ? buildCombinedNutrientQuery(macroFilters) : undefined; const params: any = { page: pageNum, search, @@ -155,7 +157,7 @@ const Foods = () => { // Refetch when shouldFetch flag is set (for pagination and search) useEffect(() => { if (shouldFetch) { - fetchFoods(page, searchTerm, sortBy, sortOrder, micronutrientFilters, macronutrientFilters); + fetchFoods(page, searchTerm, sortBy, sortOrder, nutrientFilters); setShouldFetch(false); } }, [shouldFetch]); @@ -165,23 +167,16 @@ const Foods = () => { // Skip if this is initial render (sortBy will be empty string on mount) if (sortBy !== undefined && sortBy !== '') { console.log("Sort changed, fetching with:", { sortBy, sortOrder, page, searchTerm }); - fetchFoods(page, searchTerm, sortBy, sortOrder, micronutrientFilters, macronutrientFilters); + fetchFoods(page, searchTerm, sortBy, sortOrder, nutrientFilters); } }, [sortBy, sortOrder]); - // Refetch when micronutrient filters change + // Refetch when nutrient filters change useEffect(() => { setPage(1); setLoading(true); - fetchFoods(1, searchTerm, sortBy, sortOrder, micronutrientFilters, macronutrientFilters); - }, [micronutrientFilters]); - - // Refetch when macronutrient filters change - useEffect(() => { - setPage(1); - setLoading(true); - fetchFoods(1, searchTerm, sortBy, sortOrder, micronutrientFilters, macronutrientFilters); - }, [macronutrientFilters]); + fetchFoods(1, searchTerm, sortBy, sortOrder, nutrientFilters); + }, [nutrientFilters]); const effectivePageSize = pageSize || (foods.length > 0 ? foods.length : 1) const totalPages = count ? Math.max(1, Math.ceil(count / effectivePageSize)) : 1; @@ -199,18 +194,20 @@ const Foods = () => { let newSortBy = sortBy; let newSortOrder = sortOrder; - if (key === '') { - newSortBy = ''; - newSortOrder = 'desc'; - } else { - if (sortBy === key) { - console.log("Toggling sort order from", sortOrder, "to", sortOrder === 'desc' ? 'asc' : 'desc'); - newSortOrder = sortOrder === 'desc' ? 'asc' : 'desc'; + if (sortBy === key) { + // Same option clicked - cycle through: desc -> asc -> remove + if (sortOrder === 'desc') { + // Second click: toggle to asc + newSortOrder = 'asc'; } else { - console.log("Setting new sort by:", key); - newSortBy = key; + // Third click: remove sort + newSortBy = ''; newSortOrder = 'desc'; } + } else { + // Different option clicked: set new sort (default desc) + newSortBy = key; + newSortOrder = 'desc'; } // Update state @@ -224,7 +221,7 @@ const Foods = () => { // Use empty search to show all foods with the new sort setSearchTerm(''); - fetchFoods(1, '', newSortBy, newSortOrder, micronutrientFilters, macronutrientFilters); + fetchFoods(1, '', newSortBy, newSortOrder, nutrientFilters); }; const clearSearch = () => { @@ -252,68 +249,21 @@ const Foods = () => {

- Sort Options + Filters

- - {/* Current sort indicator */} - {sortBy && ( -
-

- Sorting: {SORT_OPTIONS.find(opt => opt.key === sortBy)?.label || 'Custom'} - {sortOrder === 'desc' ? '↓' : '↑'} -

-
- )} - -
- {/* Sort buttons */} - {SORT_OPTIONS.map(option => ( - - ))} -
- {/* Micronutrient Filters */} -
- -
- - {/* Macronutrient Filters */} -
- -
+ {/* Nutrient Filters */} +
{/* Middle column - Food items */}
{/* Search bar */} -
+
@@ -346,6 +296,33 @@ const Foods = () => {
+ {/* Sort options */} +
+
+ {SORT_OPTIONS.map(option => ( + + ))} +
+
+ {warning && (
{warning}