From 433d8d3c07d1f4d35961128ada9758fca3c3da3c Mon Sep 17 00:00:00 2001 From: Jeff Loiselle Date: Sat, 29 Nov 2025 13:40:28 -0600 Subject: [PATCH 1/2] feat: implement recipe management features including create and edit functionality - Added screens for creating and editing recipes. - Implemented validation for recipe forms. - Integrated saved food management with the ability to list, create, and update saved foods. - Enhanced the food search functionality to include saved foods. - Updated theme constants for recipe tools. - Added actionlint script for workflow linting. --- .husky/pre-commit | 1 + app/(tabs)/tools/_layout.tsx | 3 + app/(tabs)/tools/index.tsx | 8 + app/(tabs)/tools/recipes/create.tsx | 447 +++++++++++++++ app/(tabs)/tools/recipes/edit.tsx | 540 ++++++++++++++++++ app/(tabs)/tools/recipes/index.tsx | 281 +++++++++ app/(tabs)/tools/recipes/nutritionFields.ts | 111 ++++ app/(tabs)/tools/recipes/validation.ts | 81 +++ app/food-search.tsx | 79 ++- components/ui/icon-symbol.tsx | 1 + constants/theme.ts | 12 + package-lock.json | 9 + package.json | 17 +- scripts/actionlint.mjs | 94 +++ services/foodSearch/index.ts | 2 +- services/foods.ts | 147 +++++ .../20251129120000_create_foods_table.sql | 36 ++ .../20251129130000_extend_foods_nutrition.sql | 17 + types/index.ts | 123 ++++ 19 files changed, 2004 insertions(+), 5 deletions(-) create mode 100644 app/(tabs)/tools/recipes/create.tsx create mode 100644 app/(tabs)/tools/recipes/edit.tsx create mode 100644 app/(tabs)/tools/recipes/index.tsx create mode 100644 app/(tabs)/tools/recipes/nutritionFields.ts create mode 100644 app/(tabs)/tools/recipes/validation.ts create mode 100644 scripts/actionlint.mjs create mode 100644 services/foods.ts create mode 100644 supabase/migrations/20251129120000_create_foods_table.sql create mode 100644 supabase/migrations/20251129130000_extend_foods_nutrition.sql diff --git a/.husky/pre-commit b/.husky/pre-commit index 0a291b2..7d95356 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -1 +1,2 @@ +node ./scripts/actionlint.mjs .github/workflows npm run web:build diff --git a/app/(tabs)/tools/_layout.tsx b/app/(tabs)/tools/_layout.tsx index 793c5b6..341d990 100644 --- a/app/(tabs)/tools/_layout.tsx +++ b/app/(tabs)/tools/_layout.tsx @@ -17,6 +17,9 @@ export default function ToolsLayout() { + + + ); diff --git a/app/(tabs)/tools/index.tsx b/app/(tabs)/tools/index.tsx index bfb9261..23ea954 100644 --- a/app/(tabs)/tools/index.tsx +++ b/app/(tabs)/tools/index.tsx @@ -24,6 +24,14 @@ const toolCards = [ accentKey: 'toolCardFeed', route: '/tools/feed', }, + { + id: 'recipes', + title: 'Recipes', + description: 'Save your custom foods for quick logging.', + icon: 'book.closed.fill', + accentKey: 'toolCardRecipes', + route: '/tools/recipes', + }, { id: 'weight', title: 'Weight', diff --git a/app/(tabs)/tools/recipes/create.tsx b/app/(tabs)/tools/recipes/create.tsx new file mode 100644 index 0000000..764cf09 --- /dev/null +++ b/app/(tabs)/tools/recipes/create.tsx @@ -0,0 +1,447 @@ +import { useRouter } from 'expo-router'; +import type { Ref } from 'react'; +import { useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { ThemedText } from '@/components/themed-text'; +import type { Theme } from '@/constants/theme'; +import { useAppTheme } from '@/providers/ThemePreferenceProvider'; +import { createFood } from '@/services/foods'; +import { createEmptyNutritionValues, nutritionFieldConfigs, type NutritionFieldKey, type NutritionFormValues } from './nutritionFields'; +import { validateFoodForm, type FoodFormErrors } from './validation'; + +export default function CreateFoodScreen() { + const router = useRouter(); + const { theme } = useAppTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + const [brandName, setBrandName] = useState(''); + const [description, setDescription] = useState(''); + const [servingSize, setServingSize] = useState(''); + const [servingsPerContainer, setServingsPerContainer] = useState('1'); + const [submitting, setSubmitting] = useState(false); + const [fieldErrors, setFieldErrors] = useState({}); + const [bannerError, setBannerError] = useState(null); + const [nutritionValues, setNutritionValues] = useState(createEmptyNutritionValues()); + const scrollViewRef = useRef(null); + const descriptionRef = useRef(null); + const servingSizeRef = useRef(null); + const servingsPerContainerRef = useRef(null); + const nutritionInputRefs = useRef>({} as Record); + + const handleNutritionChange = (key: NutritionFieldKey, value: string) => { + setNutritionValues((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const focusFirstError = (errors: FoodFormErrors) => { + if (errors.description && descriptionRef.current) { + descriptionRef.current.focus(); + return; + } + if (errors.servingSize && servingSizeRef.current) { + servingSizeRef.current.focus(); + return; + } + if (errors.servingsPerContainer && servingsPerContainerRef.current) { + servingsPerContainerRef.current.focus(); + return; + } + if (errors.nutrition) { + for (const field of nutritionFieldConfigs) { + if (errors.nutrition?.[field.key]) { + nutritionInputRefs.current[field.key]?.focus(); + return; + } + } + } + }; + + const handleSubmit = async () => { + setBannerError(null); + const { errors, isValid, servingsValue, nutritionPayload } = validateFoodForm({ + description, + servingSize, + servingsPerContainer, + nutritionValues, + }); + + setFieldErrors(errors); + + if (!isValid || !servingsValue || !nutritionPayload) { + setBannerError('Please fix the highlighted fields below.'); + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + focusFirstError(errors); + return; + } + + setSubmitting(true); + try { + await createFood({ + brandName: brandName.trim() || undefined, + description: description.trim(), + servingSize: servingSize.trim(), + servingsPerContainer: servingsValue, + ...nutritionPayload, + }); + setFieldErrors({}); + router.back(); + } catch (error: any) { + setBannerError(error?.message ?? 'Unable to save food right now.'); + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + } finally { + setSubmitting(false); + } + }; + + return ( + + + + + Create Food + + Add the details for this food so you can reuse it later. + + + + {bannerError ? ( + + Needs attention + {bannerError} + + ) : null} + + + + + + + + + + Nutrition Facts + + {nutritionFieldConfigs.map((field, index) => ( + handleNutritionChange(field.key, text)} + theme={theme} + isLast={index === nutritionFieldConfigs.length - 1} + inputRef={(ref) => { + nutritionInputRefs.current[field.key] = ref; + }} + errorText={fieldErrors.nutrition?.[field.key]} + /> + ))} + + + + + {submitting ? ( + + ) : ( + Save Food + )} + + + + + ); +} + +const InputField = ({ + label, + value, + onChangeText, + placeholder, + theme, + optional, + required, + keyboardType, + inputRef, + errorText, +}: { + label: string; + value: string; + onChangeText: (value: string) => void; + placeholder?: string; + theme: Theme; + optional?: boolean; + required?: boolean; + keyboardType?: 'default' | 'decimal-pad' | 'number-pad'; + inputRef?: Ref; + errorText?: string; +}) => { + const autoCap = keyboardType && keyboardType !== 'default' ? 'none' : 'sentences'; + return ( + + + {label} + {required ? ( + Required + ) : optional ? ( + Optional + ) : null} + + + {errorText ? {errorText} : null} + + ); +}; + +const NutritionFieldRow = ({ + label, + unit, + value, + required, + onChangeText, + theme, + isLast, + inputRef, + errorText, +}: { + label: string; + unit?: string; + value: string; + required?: boolean; + onChangeText: (val: string) => void; + theme: Theme; + isLast?: boolean; + inputRef?: Ref; + errorText?: string; +}) => { + const showError = Boolean(errorText); + return ( + + + {label} + {unit ? {unit} : null} + {showError ? ( + {errorText} + ) : null} + + + + ); +}; + +const createStyles = (theme: Theme) => + StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: theme.background, + }, + flex: { + flex: 1, + }, + content: { + paddingHorizontal: 20, + paddingBottom: 40, + gap: 20, + }, + header: { + marginTop: 16, + gap: 6, + }, + subtitle: { + color: theme.textSecondary, + }, + formGroup: { + gap: 16, + }, + section: { + gap: 8, + }, + sectionLabel: { + fontSize: 15, + fontWeight: '600', + color: theme.text, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + nutritionList: { + borderRadius: 16, + backgroundColor: theme.card, + overflow: 'hidden', + }, + submitButton: { + borderRadius: 18, + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + }, + submitButtonDisabled: { + opacity: 0.7, + }, + submitButtonText: { + fontSize: 16, + fontWeight: '600', + }, + banner: { + borderRadius: 14, + borderWidth: 1, + padding: 12, + gap: 4, + backgroundColor: theme.card, + }, + bannerTitle: { + fontSize: 13, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + bannerMessage: { + fontSize: 14, + color: theme.text, + }, + }); + +const stylesField = StyleSheet.create({ + field: { + gap: 8, + }, + labelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + labelText: { + fontSize: 15, + fontWeight: '600', + }, + metaText: { + fontSize: 13, + }, + input: { + borderWidth: 1, + borderRadius: 14, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 16, + }, + errorText: { + fontSize: 13, + }, +}); + +const stylesNutrition = StyleSheet.create({ + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + }, + labelColumn: { + flex: 1, + paddingRight: 12, + }, + label: { + fontSize: 15, + fontWeight: '500', + }, + unit: { + fontSize: 12, + }, + input: { + minWidth: 80, + fontSize: 15, + }, + errorText: { + fontSize: 12, + marginTop: 4, + }, +}); + diff --git a/app/(tabs)/tools/recipes/edit.tsx b/app/(tabs)/tools/recipes/edit.tsx new file mode 100644 index 0000000..dccebfb --- /dev/null +++ b/app/(tabs)/tools/recipes/edit.tsx @@ -0,0 +1,540 @@ +import { useLocalSearchParams, useRouter } from 'expo-router'; +import type { Ref } from 'react'; +import { useEffect, useMemo, useRef, useState } from 'react'; +import { + ActivityIndicator, + KeyboardAvoidingView, + Platform, + ScrollView, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +import { ThemedText } from '@/components/themed-text'; +import type { Theme } from '@/constants/theme'; +import { useAppTheme } from '@/providers/ThemePreferenceProvider'; +import { getFoodById, updateFood } from '@/services/foods'; +import { + createEmptyNutritionValues, + createNutritionValuesFromFood, + nutritionFieldConfigs, + type NutritionFieldKey, + type NutritionFormValues, +} from './nutritionFields'; +import { validateFoodForm, type FoodFormErrors } from './validation'; + +export default function EditFoodScreen() { + const router = useRouter(); + const params = useLocalSearchParams<{ id?: string }>(); + const foodId = typeof params.id === 'string' ? params.id : undefined; + const { theme } = useAppTheme(); + const styles = useMemo(() => createStyles(theme), [theme]); + const [brandName, setBrandName] = useState(''); + const [description, setDescription] = useState(''); + const [servingSize, setServingSize] = useState(''); + const [servingsPerContainer, setServingsPerContainer] = useState('1'); + const [submitting, setSubmitting] = useState(false); + const [fieldErrors, setFieldErrors] = useState({}); + const [bannerError, setBannerError] = useState(null); + const [initialLoading, setInitialLoading] = useState(true); + const [initialError, setInitialError] = useState(null); + const [nutritionValues, setNutritionValues] = useState(createEmptyNutritionValues()); + const scrollViewRef = useRef(null); + const descriptionRef = useRef(null); + const servingSizeRef = useRef(null); + const servingsPerContainerRef = useRef(null); + const nutritionInputRefs = useRef>({} as Record); + + const handleNutritionChange = (key: NutritionFieldKey, value: string) => { + setNutritionValues((prev) => ({ + ...prev, + [key]: value, + })); + }; + + const focusFirstError = (errors: FoodFormErrors) => { + if (errors.description && descriptionRef.current) { + descriptionRef.current.focus(); + return; + } + if (errors.servingSize && servingSizeRef.current) { + servingSizeRef.current.focus(); + return; + } + if (errors.servingsPerContainer && servingsPerContainerRef.current) { + servingsPerContainerRef.current.focus(); + return; + } + if (errors.nutrition) { + for (const field of nutritionFieldConfigs) { + if (errors.nutrition?.[field.key]) { + nutritionInputRefs.current[field.key]?.focus(); + return; + } + } + } + }; + + useEffect(() => { + let isMounted = true; + async function loadFood() { + if (!foodId) { + setInitialError('Missing food identifier.'); + setInitialLoading(false); + return; + } + try { + const food = await getFoodById(foodId); + if (!isMounted) return; + setBrandName(food.brandName ?? ''); + setDescription(food.description); + setServingSize(food.servingSize); + setServingsPerContainer(String(food.servingsPerContainer)); + setNutritionValues(createNutritionValuesFromFood(food)); + setFieldErrors({}); + setBannerError(null); + } catch (error: any) { + if (!isMounted) return; + setInitialError(error?.message ?? 'Unable to load this food.'); + } finally { + if (isMounted) { + setInitialLoading(false); + } + } + } + loadFood(); + return () => { + isMounted = false; + }; + }, [foodId]); + + const handleSubmit = async () => { + setBannerError(null); + if (!foodId) { + setBannerError('Missing food identifier.'); + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + return; + } + + const { errors, isValid, servingsValue, nutritionPayload } = validateFoodForm({ + description, + servingSize, + servingsPerContainer, + nutritionValues, + }); + + setFieldErrors(errors); + + if (!isValid || !servingsValue || !nutritionPayload) { + setBannerError('Please fix the highlighted fields below.'); + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + focusFirstError(errors); + return; + } + + setSubmitting(true); + try { + await updateFood({ + id: foodId, + brandName: brandName.trim() || undefined, + description: description.trim(), + servingSize: servingSize.trim(), + servingsPerContainer: servingsValue, + ...nutritionPayload, + }); + setFieldErrors({}); + router.back(); + } catch (error: any) { + setBannerError(error?.message ?? 'Unable to save changes right now.'); + scrollViewRef.current?.scrollTo({ y: 0, animated: true }); + } finally { + setSubmitting(false); + } + }; + + const renderContent = () => { + if (initialLoading) { + return ( + + + + ); + } + + if (initialError) { + return ( + + {initialError} + router.back()} style={[styles.submitButton, { backgroundColor: theme.primary }]}> + Go Back + + + ); + } + + return ( + <> + + Edit Food + Update the details for this saved food. + + + {bannerError ? ( + + Needs attention + {bannerError} + + ) : null} + + + + + + + + + + Nutrition Facts + + {nutritionFieldConfigs.map((field, index) => ( + handleNutritionChange(field.key, text)} + theme={theme} + isLast={index === nutritionFieldConfigs.length - 1} + inputRef={(ref) => { + nutritionInputRefs.current[field.key] = ref; + }} + errorText={fieldErrors.nutrition?.[field.key]} + /> + ))} + + + + + {submitting ? ( + + ) : ( + Save Changes + )} + + + ); + }; + + return ( + + + + {renderContent()} + + + + ); +} + +const InputField = ({ + label, + value, + onChangeText, + placeholder, + theme, + optional, + required, + keyboardType, + inputRef, + errorText, +}: { + label: string; + value: string; + onChangeText: (value: string) => void; + placeholder?: string; + theme: Theme; + optional?: boolean; + required?: boolean; + keyboardType?: 'default' | 'decimal-pad' | 'number-pad'; + inputRef?: Ref; + errorText?: string; +}) => { + const autoCap = keyboardType && keyboardType !== 'default' ? 'none' : 'sentences'; + return ( + + + {label} + {required ? ( + Required + ) : optional ? ( + Optional + ) : null} + + + {errorText ? {errorText} : null} + + ); +}; + +const NutritionFieldRow = ({ + label, + unit, + value, + required, + onChangeText, + theme, + isLast, + inputRef, + errorText, +}: { + label: string; + unit?: string; + value: string; + required?: boolean; + onChangeText: (val: string) => void; + theme: Theme; + isLast?: boolean; + inputRef?: Ref; + errorText?: string; +}) => { + const showError = Boolean(errorText); + return ( + + + {label} + {unit ? {unit} : null} + {showError ? ( + {errorText} + ) : null} + + + + ); +}; + +const createStyles = (theme: Theme) => + StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: theme.background, + }, + flex: { + flex: 1, + }, + content: { + paddingHorizontal: 20, + paddingBottom: 40, + gap: 20, + }, + loadingState: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingVertical: 60, + }, + errorState: { + flex: 1, + gap: 16, + alignItems: 'center', + justifyContent: 'center', + paddingVertical: 60, + }, + header: { + marginTop: 16, + gap: 6, + }, + subtitle: { + color: theme.textSecondary, + }, + formGroup: { + gap: 16, + }, + section: { + gap: 8, + }, + sectionLabel: { + fontSize: 15, + fontWeight: '600', + color: theme.text, + textTransform: 'uppercase', + letterSpacing: 0.6, + }, + nutritionList: { + borderRadius: 16, + backgroundColor: theme.card, + overflow: 'hidden', + }, + errorText: { + color: theme.danger, + textAlign: 'center', + }, + submitButton: { + borderRadius: 18, + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + }, + submitButtonDisabled: { + opacity: 0.7, + }, + submitButtonText: { + fontSize: 16, + fontWeight: '600', + }, + banner: { + borderRadius: 14, + borderWidth: 1, + padding: 12, + gap: 4, + backgroundColor: theme.card, + }, + bannerTitle: { + fontSize: 13, + fontWeight: '600', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + bannerMessage: { + fontSize: 14, + color: theme.text, + textAlign: 'left', + }, + }); + +const stylesField = StyleSheet.create({ + field: { + gap: 8, + }, + labelRow: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + }, + labelText: { + fontSize: 15, + fontWeight: '600', + }, + metaText: { + fontSize: 13, + }, + input: { + borderWidth: 1, + borderRadius: 14, + paddingHorizontal: 14, + paddingVertical: 12, + fontSize: 16, + }, + errorText: { + fontSize: 13, + }, +}); + +const stylesNutrition = StyleSheet.create({ + row: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 16, + paddingVertical: 14, + }, + labelColumn: { + flex: 1, + paddingRight: 12, + }, + label: { + fontSize: 15, + fontWeight: '500', + }, + unit: { + fontSize: 12, + }, + input: { + minWidth: 80, + fontSize: 15, + }, + errorText: { + fontSize: 12, + marginTop: 4, + }, +}); + diff --git a/app/(tabs)/tools/recipes/index.tsx b/app/(tabs)/tools/recipes/index.tsx new file mode 100644 index 0000000..cfb6c05 --- /dev/null +++ b/app/(tabs)/tools/recipes/index.tsx @@ -0,0 +1,281 @@ +import { useFocusEffect, useRouter } from 'expo-router'; +import { useCallback, useMemo, useState } from 'react'; +import { + ActivityIndicator, + FlatList, + StyleSheet, + TextInput, + TouchableOpacity, + View, +} from 'react-native'; +import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; + +import { ThemedText } from '@/components/themed-text'; +import { ThemedView } from '@/components/themed-view'; +import { IconSymbol } from '@/components/ui/icon-symbol'; +import type { Theme } from '@/constants/theme'; +import { useAppTheme } from '@/providers/ThemePreferenceProvider'; +import { listFoods } from '@/services/foods'; +import type { SavedFood } from '@/types'; + +type RecipesStyles = ReturnType; + +export default function RecipesScreen() { + const router = useRouter(); + const { theme } = useAppTheme(); + const insets = useSafeAreaInsets(); + const styles = useMemo(() => createStyles(theme), [theme]); + const [foods, setFoods] = useState([]); + const [foodsLoading, setFoodsLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [foodsError, setFoodsError] = useState(null); + const [search, setSearch] = useState(''); + + const loadFoods = useCallback( + async (options?: { silent?: boolean }) => { + if (!options?.silent) { + setFoodsLoading(true); + } + setFoodsError(null); + try { + const data = await listFoods(); + setFoods(data); + } catch (error: any) { + setFoodsError(error?.message ?? 'Unable to load foods.'); + } finally { + if (!options?.silent) { + setFoodsLoading(false); + } + } + }, + [] + ); + + useFocusEffect( + useCallback(() => { + loadFoods(); + }, [loadFoods]) + ); + + const handleRefresh = useCallback(async () => { + setRefreshing(true); + await loadFoods({ silent: true }); + setRefreshing(false); + }, [loadFoods]); + + const filteredFoods = useMemo(() => { + const query = search.trim().toLowerCase(); + if (!query) return foods; + return foods.filter((food) => { + const haystack = `${food.description} ${food.brandName ?? ''} ${food.servingSize}`.toLowerCase(); + return haystack.includes(query); + }); + }, [foods, search]); + + const renderEmptyState = () => { + if (foodsLoading) { + return ; + } + if (foodsError) { + return ( + + {foodsError} + loadFoods()}> + Try again + + + ); + } + return ( + + Nothing here yet. Create a food to save your favorite recipes. + + ); + }; + + return ( + + + + Recipes + + Save custom foods that you prepare often and reuse them quickly. + + + + + + + + + item.id} + renderItem={({ item }) => ( + router.push({ pathname: '/tools/recipes/edit', params: { id: item.id } })} + /> + )} + contentContainerStyle={[ + styles.listContent, + { paddingBottom: 120 + insets.bottom }, + filteredFoods.length === 0 && styles.listContentCentered, + ]} + ItemSeparatorComponent={() => } + ListEmptyComponent={renderEmptyState} + refreshing={refreshing} + onRefresh={handleRefresh} + /> + + + + router.push('/tools/recipes/create')} + > + Create a Food + + + + ); +} + +const FoodRow = ({ + food, + theme, + styles, + onPress, +}: { + food: SavedFood; + theme: Theme; + styles: RecipesStyles; + onPress: () => void; +}) => { + return ( + + + + {food.description} + {food.brandName ? ( + {food.brandName} + ) : null} + + {food.servingSize} • {food.servingsPerContainer} servings • {food.calories} cal + + + + + ); +}; + +const createStyles = (theme: Theme) => + StyleSheet.create({ + safeArea: { + flex: 1, + backgroundColor: theme.background, + }, + container: { + flex: 1, + paddingHorizontal: 20, + paddingTop: 24, + paddingBottom: 32, + gap: 16, + }, + header: { + gap: 6, + }, + subtitle: { + color: theme.textSecondary, + }, + searchBar: { + flexDirection: 'row', + alignItems: 'center', + borderWidth: 1, + borderRadius: 14, + paddingHorizontal: 14, + paddingVertical: 10, + gap: 8, + }, + searchInput: { + flex: 1, + color: theme.text, + fontSize: 16, + }, + listContent: { + paddingBottom: 0, + }, + listContentCentered: { + flexGrow: 1, + justifyContent: 'center', + }, + separator: { + height: 12, + }, + foodRow: { + borderRadius: 16, + padding: 16, + }, + foodContent: { + gap: 4, + }, + foodTitle: { + fontSize: 16, + fontWeight: '600', + }, + foodSubtitle: { + color: theme.textSecondary, + fontSize: 14, + }, + foodMeta: { + color: theme.textTertiary, + fontSize: 13, + }, + loader: { + paddingTop: 24, + }, + errorBox: { + alignItems: 'center', + gap: 8, + }, + errorText: { + textAlign: 'center', + color: theme.error, + }, + retryText: { + color: theme.primary, + fontWeight: '600', + }, + emptyText: { + color: theme.textSecondary, + textAlign: 'center', + }, + createButtonWrapper: { + position: 'absolute', + left: 20, + right: 20, + bottom: 24, + }, + createButton: { + borderRadius: 18, + paddingVertical: 16, + alignItems: 'center', + justifyContent: 'center', + }, + createButtonText: { + fontSize: 16, + fontWeight: '600', + }, + }); + diff --git a/app/(tabs)/tools/recipes/nutritionFields.ts b/app/(tabs)/tools/recipes/nutritionFields.ts new file mode 100644 index 0000000..1360006 --- /dev/null +++ b/app/(tabs)/tools/recipes/nutritionFields.ts @@ -0,0 +1,111 @@ +import type { SavedFood, SavedFoodInput } from '@/types'; + +export type NutritionFieldKey = + | 'calories' + | 'totalFat' + | 'saturatedFat' + | 'polyunsaturatedFat' + | 'monounsaturatedFat' + | 'transFat' + | 'cholesterol' + | 'sodium' + | 'potassium' + | 'totalCarbohydrates' + | 'dietaryFiber' + | 'sugars' + | 'addedSugars' + | 'sugarAlcohols' + | 'protein'; + +export type NutritionFieldConfig = { + key: NutritionFieldKey; + label: string; + unit?: string; + parser: 'int' | 'float'; + required?: boolean; +}; + +const nutritionFieldConfigsConst = [ + { key: 'calories', label: 'Calories', unit: '', required: true, parser: 'int' }, + { key: 'totalFat', label: 'Total Fat', unit: 'g', parser: 'float' }, + { key: 'saturatedFat', label: 'Saturated Fat', unit: 'g', parser: 'float' }, + { key: 'polyunsaturatedFat', label: 'Polyunsaturated Fat', unit: 'g', parser: 'float' }, + { key: 'monounsaturatedFat', label: 'Monounsaturated', unit: 'g', parser: 'float' }, + { key: 'transFat', label: 'Trans Fat', unit: 'g', parser: 'float' }, + { key: 'cholesterol', label: 'Cholesterol', unit: 'mg', parser: 'int' }, + { key: 'sodium', label: 'Sodium', unit: 'mg', parser: 'int' }, + { key: 'potassium', label: 'Potassium', unit: 'mg', parser: 'int' }, + { key: 'totalCarbohydrates', label: 'Total Carbohydrates', unit: 'g', parser: 'float' }, + { key: 'dietaryFiber', label: 'Dietary Fiber', unit: 'g', parser: 'float' }, + { key: 'sugars', label: 'Sugars', unit: 'g', parser: 'float' }, + { key: 'addedSugars', label: 'Added Sugars', unit: 'g', parser: 'float' }, + { key: 'sugarAlcohols', label: 'Sugar Alcohols', unit: 'g', parser: 'float' }, + { key: 'protein', label: 'Protein', unit: 'g', parser: 'float' }, +] as const; + +export const nutritionFieldConfigs: readonly NutritionFieldConfig[] = nutritionFieldConfigsConst; + +export type NutritionFormValues = Record; + +export const createEmptyNutritionValues = (): NutritionFormValues => + nutritionFieldConfigs.reduce( + (acc, field) => ({ + ...acc, + [field.key]: '', + }), + {} as NutritionFormValues + ); + +const formatNumber = (value?: number) => { + if (value === undefined || value === null) return ''; + if (Number.isInteger(value)) return value.toString(); + return value.toString(); +}; + +export const createNutritionValuesFromFood = (food?: SavedFood): NutritionFormValues => { + const base = createEmptyNutritionValues(); + if (!food) return base; + + return { + ...base, + calories: formatNumber(food.calories), + totalFat: formatNumber(food.totalFat), + saturatedFat: formatNumber(food.saturatedFat), + polyunsaturatedFat: formatNumber(food.polyunsaturatedFat), + monounsaturatedFat: formatNumber(food.monounsaturatedFat), + transFat: formatNumber(food.transFat), + cholesterol: formatNumber(food.cholesterol), + sodium: formatNumber(food.sodium), + potassium: formatNumber(food.potassium), + totalCarbohydrates: formatNumber(food.totalCarbohydrates), + dietaryFiber: formatNumber(food.dietaryFiber), + sugars: formatNumber(food.sugars), + addedSugars: formatNumber(food.addedSugars), + sugarAlcohols: formatNumber(food.sugarAlcohols), + protein: formatNumber(food.protein), + }; +}; + +export type NutritionPayload = Partial>; + +export const buildNutritionPayload = (values: NutritionFormValues): NutritionPayload => { + const payload: NutritionPayload = {}; + + nutritionFieldConfigs.forEach((field) => { + const rawValue = values[field.key]?.trim(); + if (!rawValue) return; + const parsed = + field.parser === 'int' ? parseInt(rawValue, 10) : parseFloat(rawValue); + if (Number.isFinite(parsed)) { + payload[field.key] = parsed; + } + }); + + return payload; +}; + +export const nutritionPayloadHasCalories = ( + payload: NutritionPayload +): payload is NutritionPayload & Required> => + typeof payload.calories === 'number' && Number.isFinite(payload.calories); + diff --git a/app/(tabs)/tools/recipes/validation.ts b/app/(tabs)/tools/recipes/validation.ts new file mode 100644 index 0000000..1905865 --- /dev/null +++ b/app/(tabs)/tools/recipes/validation.ts @@ -0,0 +1,81 @@ +import type { SavedFoodInput } from '@/types'; + +import type { NutritionFieldKey, NutritionFormValues, NutritionPayload } from './nutritionFields'; +import { buildNutritionPayload, nutritionPayloadHasCalories } from './nutritionFields'; + +export type FoodFormErrors = { + description?: string; + servingSize?: string; + servingsPerContainer?: string; + nutrition?: Partial>; +}; + +export type ValidateFoodFormArgs = { + description: string; + servingSize: string; + servingsPerContainer: string; + nutritionValues: NutritionFormValues; +}; + +export type NutritionPayloadWithCalories = NutritionPayload & Required>; + +export type FoodFormValidationResult = { + errors: FoodFormErrors; + isValid: boolean; + servingsValue?: number; + nutritionPayload?: NutritionPayloadWithCalories; +}; + +const hasNutritionErrors = (nutritionErrors?: Partial>) => + Boolean(nutritionErrors && Object.keys(nutritionErrors).length > 0); + +export const validateFoodForm = ({ + description, + servingSize, + servingsPerContainer, + nutritionValues, +}: ValidateFoodFormArgs): FoodFormValidationResult => { + const errors: FoodFormErrors = {}; + let servingsValue: number | undefined; + + if (!description.trim()) { + errors.description = 'Description is required.'; + } + + if (!servingSize.trim()) { + errors.servingSize = 'Serving size is required.'; + } + + const parsedServings = Number(servingsPerContainer); + if (!Number.isFinite(parsedServings) || parsedServings <= 0) { + errors.servingsPerContainer = 'Servings per container must be greater than 0.'; + } else { + servingsValue = parsedServings; + } + + const nutritionPayload = buildNutritionPayload(nutritionValues); + let ensuredNutritionPayload: NutritionPayloadWithCalories | undefined; + if (!nutritionPayloadHasCalories(nutritionPayload)) { + errors.nutrition = { + calories: 'Calories are required.', + }; + } else { + ensuredNutritionPayload = nutritionPayload; + } + + const isValid = + !errors.description && + !errors.servingSize && + !errors.servingsPerContainer && + !hasNutritionErrors(errors.nutrition) && + Boolean(ensuredNutritionPayload); + + return { + errors, + isValid, + servingsValue, + nutritionPayload: isValid ? ensuredNutritionPayload : undefined, + }; +}; + + diff --git a/app/food-search.tsx b/app/food-search.tsx index e692d6c..3b5a6e1 100644 --- a/app/food-search.tsx +++ b/app/food-search.tsx @@ -1,7 +1,7 @@ import MaterialIcons from '@expo/vector-icons/MaterialIcons'; import { Audio } from 'expo-av'; import { useRouter } from 'expo-router'; -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ActivityIndicator, Alert, @@ -19,6 +19,7 @@ import type { Theme } from '@/constants/theme'; import { useAppTheme } from '@/providers/ThemePreferenceProvider'; import { getOpenAiApiKey, transcribeAudioFile } from '@/services/ai'; import { searchFoods } from '@/services/foodSearch'; +import { listFoods, savedFoodToFoodItem } from '@/services/foods'; import { createMeal, mapFoodToMealInput } from '@/services/meals'; import type { FoodItem, MealType } from '@/types'; @@ -33,6 +34,8 @@ const isAbortError = (error: unknown): boolean => export default function FoodSearch() { const [searchQuery, setSearchQuery] = useState(''); const [results, setResults] = useState([]); + const [savedResults, setSavedResults] = useState([]); + const [savedResultsLoading, setSavedResultsLoading] = useState(false); const [searching, setSearching] = useState(false); const [recording, setRecording] = useState(null); const [isRecording, setIsRecording] = useState(false); @@ -50,6 +53,7 @@ export default function FoodSearch() { const hasSearchQuery = trimmedSearchQuery.length > 0; const searchAbortController = useRef(null); const latestSearchRequestId = useRef(0); + const savedSearchRequestId = useRef(0); const searchFood = async (query: string, page = 1, append = false) => { const normalizedQuery = query.trim(); @@ -202,9 +206,10 @@ export default function FoodSearch() { }; const dynamicStyles = createStyles(theme); + const combinedResults = useMemo(() => [...savedResults, ...results], [savedResults, results]); const filteredResults = useMemo( - () => (onlyBranded ? results.filter((item) => Boolean(item.brand)) : results), - [results, onlyBranded] + () => (onlyBranded ? combinedResults.filter((item) => Boolean(item.brand)) : combinedResults), + [combinedResults, onlyBranded] ); const renderFoodItem = ({ item }: { item: FoodItem }) => ( @@ -217,6 +222,11 @@ export default function FoodSearch() { )} + {item.id.startsWith('saved_food_') && ( + + Yours + + )} {item.calories} cal, {item.serving} @@ -237,6 +247,8 @@ export default function FoodSearch() { useEffect(() => { if (!trimmedSearchQuery) { setResults([]); + setSavedResults([]); + setSavedResultsLoading(false); setSearching(false); setHasMore(false); setCurrentPage(1); @@ -250,6 +262,39 @@ export default function FoodSearch() { return () => clearTimeout(timeout); }, [trimmedSearchQuery]); + const searchSavedFoods = useCallback( + async (query: string) => { + const normalizedQuery = query.trim(); + if (!normalizedQuery) { + setSavedResults([]); + setSavedResultsLoading(false); + return; + } + + const requestId = savedSearchRequestId.current + 1; + savedSearchRequestId.current = requestId; + setSavedResultsLoading(true); + try { + const foods = await listFoods(normalizedQuery); + if (savedSearchRequestId.current === requestId) { + setSavedResults(foods.map(savedFoodToFoodItem)); + } + } catch (error) { + console.error('Saved foods search error:', error); + } finally { + if (savedSearchRequestId.current === requestId) { + setSavedResultsLoading(false); + } + } + }, + [] + ); + + useEffect(() => { + if (!trimmedSearchQuery) return; + searchSavedFoods(trimmedSearchQuery); + }, [searchSavedFoods, trimmedSearchQuery]); + useEffect(() => { return () => { if (searchAbortController.current) { @@ -331,6 +376,12 @@ export default function FoodSearch() { + {savedResultsLoading && ( + + + Loading your foods… + + )} StyleSheet.create({ justifyContent: 'space-between', alignItems: 'center', }, + savedLoadingRow: { + flexDirection: 'row', + alignItems: 'center', + gap: 6, + paddingHorizontal: 20, + paddingBottom: 8, + }, + savedLoadingText: { + fontSize: 12, + color: theme.textSecondary, + }, resultsTitle: { fontSize: 18, fontWeight: '600', @@ -508,6 +570,17 @@ const createStyles = (theme: Theme) => StyleSheet.create({ borderRadius: 999, backgroundColor: 'rgba(10,132,255,0.15)', }, + savedBadge: { + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 999, + backgroundColor: theme.primary, + }, + savedBadgeText: { + fontSize: 11, + fontWeight: '600', + color: theme.onPrimary, + }, foodServing: { fontSize: 13, color: theme.textSecondary, diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx index 7290bd7..5f74e03 100644 --- a/components/ui/icon-symbol.tsx +++ b/components/ui/icon-symbol.tsx @@ -39,6 +39,7 @@ const MAPPING: IconMapping = { 'mic.fill': 'mic', 'paintbrush.fill': 'brush', 'heart.fill': 'favorite', + 'book.closed.fill': 'menu-book', 'checkmark.circle.fill': 'check-circle', 'plus.circle.fill': 'add-circle', 'arrow.clockwise.circle': 'refresh', diff --git a/constants/theme.ts b/constants/theme.ts index 4cc42d4..685fba5 100644 --- a/constants/theme.ts +++ b/constants/theme.ts @@ -49,6 +49,7 @@ const fitblue = { toolCardBloodPressure: '#FF375F', toolCardWeight: '#5AC8FA', toolCardFeed: '#AF52DE', + toolCardRecipes: '#00C6AE', metricProtein: '#FFB347', metricCarbs: '#34C759', metricFat: '#FF6B6B', @@ -93,6 +94,7 @@ const fitblue = { toolCardBloodPressure: '#FF375F', toolCardWeight: '#5AC8FA', toolCardFeed: '#BF5AF2', + toolCardRecipes: '#00C6AE', metricProtein: '#FFB347', metricCarbs: '#34C759', metricFat: '#FF6B6B', @@ -140,6 +142,7 @@ const healthyGreen = { toolCardBloodPressure: '#FF6B6B', toolCardWeight: '#21B573', toolCardFeed: '#2FB177', + toolCardRecipes: '#20B486', metricProtein: '#F5B94C', metricCarbs: '#25B874', metricFat: '#FF6B6B', @@ -184,6 +187,7 @@ const healthyGreen = { toolCardBloodPressure: '#FF6B6B', toolCardWeight: '#20D18A', toolCardFeed: '#25D48C', + toolCardRecipes: '#20B486', metricProtein: '#F5B94C', metricCarbs: '#32E28F', metricFat: '#FF6B6B', @@ -231,6 +235,7 @@ const modern = { toolCardBloodPressure: '#F43F5E', toolCardWeight: '#0EA5E9', toolCardFeed: '#F472B6', + toolCardRecipes: '#0EA5E9', metricProtein: '#FACC15', metricCarbs: '#22C55E', metricFat: '#F43F5E', @@ -275,6 +280,7 @@ const modern = { toolCardBloodPressure: '#FB7185', toolCardWeight: '#38BDF8', toolCardFeed: '#F783AC', + toolCardRecipes: '#38BDF8', metricProtein: '#FACC15', metricCarbs: '#22C55E', metricFat: '#FB7185', @@ -322,6 +328,7 @@ const sunsetPulse = { toolCardBloodPressure: '#FF6B88', toolCardWeight: '#FFB84D', toolCardFeed: '#9C1DE7', + toolCardRecipes: '#FFB84D', metricProtein: '#FFB84D', metricCarbs: '#4CC9F0', metricFat: '#FF6B88', @@ -366,6 +373,7 @@ const sunsetPulse = { toolCardBloodPressure: '#FF99B0', toolCardWeight: '#FFCE73', toolCardFeed: '#C77DFF', + toolCardRecipes: '#FFCE73', metricProtein: '#FFCE73', metricCarbs: '#4CC9F0', metricFat: '#FF99B0', @@ -413,6 +421,7 @@ const auroraGlow = { toolCardBloodPressure: '#FF5F79', toolCardWeight: '#2FD5A7', toolCardFeed: '#C084FC', + toolCardRecipes: '#2FD5A7', metricProtein: '#FFB347', metricCarbs: '#2FD5A7', metricFat: '#FF5F79', @@ -457,6 +466,7 @@ const auroraGlow = { toolCardBloodPressure: '#FF9BD8', toolCardWeight: '#31EDB9', toolCardFeed: '#D8B4FE', + toolCardRecipes: '#31EDB9', metricProtein: '#FFCB6B', metricCarbs: '#31EDB9', metricFat: '#FF9BD8', @@ -504,6 +514,7 @@ const nordicFrost = { toolCardBloodPressure: '#4C9FE5', toolCardWeight: '#7ADCC5', toolCardFeed: '#6C7C99', + toolCardRecipes: '#F2B880', metricProtein: '#F2B880', metricCarbs: '#7ADCC5', metricFat: '#F28B82', @@ -548,6 +559,7 @@ const nordicFrost = { toolCardBloodPressure: '#65B8FF', toolCardWeight: '#7ADCC5', toolCardFeed: '#8EA2C8', + toolCardRecipes: '#F2B880', metricProtein: '#F2B880', metricCarbs: '#7ADCC5', metricFat: '#F28B82', diff --git a/package-lock.json b/package-lock.json index 6aa00a3..d87d1b1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "magicmeal", "version": "1.0.0", "hasInstallScript": true, + "license": "ISC", "dependencies": { "@ai-sdk/openai": "^2.0.73", "@expo/vector-icons": "^15.0.3", @@ -68,6 +69,7 @@ "@semantic-release/npm": "^13.1.2", "@semantic-release/release-notes-generator": "^14.1.0", "@types/react": "~19.1.0", + "actionlint": "^2.0.6", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "eslint": "^9.25.0", @@ -5956,6 +5958,13 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/actionlint": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/actionlint/-/actionlint-2.0.6.tgz", + "integrity": "sha512-tNx8f48yJNSLXTIygGntu5dSZlIZblDt8sR7pIs7EEmmb2PkEF87d+3UKZV2GUgCuN9Awj7k4ZPrwdAxFWEqgw==", + "dev": true, + "license": "MIT" + }, "node_modules/agent-base": { "version": "7.1.4", "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", diff --git a/package.json b/package.json index 8b19a3c..3a0ad42 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "start": "expo start", "reset-project": "node ./scripts/reset-project.js", + "biz:reddit-scout": "node ./biz/reddit-scout.mjs", "android": "expo start --android", "ios": "expo start --ios", "web": "expo start --web", @@ -74,6 +75,7 @@ "@semantic-release/npm": "^13.1.2", "@semantic-release/release-notes-generator": "^14.1.0", "@types/react": "~19.1.0", + "actionlint": "^2.0.6", "commitizen": "^4.3.1", "cz-conventional-changelog": "^3.3.0", "eslint": "^9.25.0", @@ -87,5 +89,18 @@ "commitizen": { "path": "cz-conventional-changelog" } - } + }, + "description": "AI-powered 💪 calorie tracker 🥗 for food, weight ⚖️, blood pressure ❤️, and recipe tracking 🍳 — built with React Native + Expo. Track your meals effortlessly with barcode scanning and AI photo recognition.", + "directories": { + "lib": "lib" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/phishy/magicmeal.git" + }, + "author": "", + "bugs": { + "url": "https://github.com/phishy/magicmeal/issues" + }, + "homepage": "https://github.com/phishy/magicmeal#readme" } diff --git a/scripts/actionlint.mjs b/scripts/actionlint.mjs new file mode 100644 index 0000000..83798f2 --- /dev/null +++ b/scripts/actionlint.mjs @@ -0,0 +1,94 @@ +#!/usr/bin/env node + +import { createLinter } from "actionlint"; +import { readFile, readdir, stat } from "node:fs/promises"; +import path from "node:path"; +import process from "node:process"; + +const WORKFLOW_EXTENSIONS = new Set([".yml", ".yaml"]); + +const targets = process.argv.slice(2); +const searchRoots = targets.length > 0 ? targets : [".github/workflows"]; + +/** + * Recursively collect workflow files under provided roots. + * @param {string[]} roots + * @returns {Promise} + */ +async function collectWorkflowFiles(roots) { + const files = []; + + for (const root of roots) { + const absolute = path.resolve(process.cwd(), root); + try { + const info = await stat(absolute); + if (info.isDirectory()) { + files.push(...(await walkDirectory(absolute))); + } else if (info.isFile() && isWorkflowFile(absolute)) { + files.push(absolute); + } + } catch (error) { + console.warn(`Skipping ${root}: ${error.message}`); + } + } + + return files; +} + +async function walkDirectory(directory) { + const entries = await readdir(directory, { withFileTypes: true }); + const files = []; + + for (const entry of entries) { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + files.push(...(await walkDirectory(entryPath))); + } else if (entry.isFile() && isWorkflowFile(entryPath)) { + files.push(entryPath); + } + } + + return files; +} + +function isWorkflowFile(filePath) { + return WORKFLOW_EXTENSIONS.has(path.extname(filePath).toLowerCase()); +} + +/** + * Run actionlint against the selected files. + */ +async function main() { + const files = await collectWorkflowFiles(searchRoots); + if (files.length === 0) { + console.log("actionlint: no workflow files to lint"); + return; + } + + const linter = await createLinter(); + let hasErrors = false; + + for (const file of files) { + const contents = await readFile(file, "utf8"); + const results = linter(contents, path.relative(process.cwd(), file)); + + for (const issue of results) { + hasErrors = true; + console.error( + `${issue.file}:${issue.line}:${issue.column} ${issue.message} (${issue.kind})` + ); + } + } + + if (hasErrors) { + process.exitCode = 1; + } else { + console.log(`actionlint: checked ${files.length} file(s)`); + } +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); + diff --git a/services/foodSearch/index.ts b/services/foodSearch/index.ts index 017a0d6..cb59799 100644 --- a/services/foodSearch/index.ts +++ b/services/foodSearch/index.ts @@ -1,3 +1,4 @@ +import { getDeveloperSettingsSnapshot } from '@/lib/developerSettingsStore'; import { aiFoodSearchAdapter } from '@/services/foodSearch/adapters/aiFoodSearchAdapter'; import { openFoodFactsAdapter } from '@/services/foodSearch/adapters/openFoodFactsAdapter'; import type { @@ -6,7 +7,6 @@ import type { FoodSearchRequest, FoodSearchResult, } from '@/types'; -import { getDeveloperSettingsSnapshot } from '@/lib/developerSettingsStore'; const ADAPTERS: Record = { [openFoodFactsAdapter.id]: openFoodFactsAdapter, diff --git a/services/foods.ts b/services/foods.ts new file mode 100644 index 0000000..59374bf --- /dev/null +++ b/services/foods.ts @@ -0,0 +1,147 @@ +import { supabase } from '@/lib/supabase'; +import { getProfileIdOrThrow } from '@/services/helpers'; +import type { + FoodItem, + SavedFood, + SavedFoodInput, + SavedFoodRecord, + SavedFoodUpdateInput, +} from '@/types'; + +const TABLE = 'foods'; + +const toNumber = (value: number | string | null | undefined): number | undefined => { + if (value === null || value === undefined) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +}; + +const mapRecord = (record: SavedFoodRecord): SavedFood => ({ + id: record.id, + profileId: record.profile_id, + brandName: record.brand_name ?? undefined, + description: record.description, + servingSize: record.serving_size, + servingsPerContainer: Number(record.servings_per_container), + calories: Number(record.calories) || 0, + totalFat: toNumber(record.total_fat), + saturatedFat: toNumber(record.saturated_fat), + polyunsaturatedFat: toNumber(record.polyunsaturated_fat), + monounsaturatedFat: toNumber(record.monounsaturated_fat), + transFat: toNumber(record.trans_fat), + cholesterol: toNumber(record.cholesterol), + sodium: toNumber(record.sodium), + potassium: toNumber(record.potassium), + totalCarbohydrates: toNumber(record.total_carbohydrates), + dietaryFiber: toNumber(record.dietary_fiber), + sugars: toNumber(record.sugars), + addedSugars: toNumber(record.added_sugars), + sugarAlcohols: toNumber(record.sugar_alcohols), + protein: toNumber(record.protein), + createdAt: record.created_at, + updatedAt: record.updated_at, +}); + +const buildNutritionPayload = (input: SavedFoodInput | SavedFoodUpdateInput) => ({ + calories: input.calories, + total_fat: input.totalFat ?? null, + saturated_fat: input.saturatedFat ?? null, + polyunsaturated_fat: input.polyunsaturatedFat ?? null, + monounsaturated_fat: input.monounsaturatedFat ?? null, + trans_fat: input.transFat ?? null, + cholesterol: input.cholesterol ?? null, + sodium: input.sodium ?? null, + potassium: input.potassium ?? null, + total_carbohydrates: input.totalCarbohydrates ?? null, + dietary_fiber: input.dietaryFiber ?? null, + sugars: input.sugars ?? null, + added_sugars: input.addedSugars ?? null, + sugar_alcohols: input.sugarAlcohols ?? null, + protein: input.protein ?? null, +}); + +export async function listFoods(search?: string): Promise { + const profileId = await getProfileIdOrThrow(); + let query = supabase + .from(TABLE) + .select('*') + .eq('profile_id', profileId) + .order('description', { ascending: true }); + + if (search?.trim()) { + query = query.ilike('description', `%${search.trim()}%`); + } + + const { data, error } = await query; + if (error) throw error; + + return data.map(mapRecord); +} + +export async function createFood(input: SavedFoodInput): Promise { + const profileId = await getProfileIdOrThrow(); + const payload = { + profile_id: profileId, + brand_name: input.brandName?.trim() || null, + description: input.description.trim(), + serving_size: input.servingSize.trim(), + servings_per_container: input.servingsPerContainer, + ...buildNutritionPayload(input), + }; + + const { data, error } = await supabase.from(TABLE).insert(payload).select('*').single(); + if (error) throw error; + + return mapRecord(data); +} + +export async function getFoodById(id: string): Promise { + const profileId = await getProfileIdOrThrow(); + const { data, error } = await supabase + .from(TABLE) + .select('*') + .eq('id', id) + .eq('profile_id', profileId) + .single(); + + if (error) throw error; + return mapRecord(data); +} + +export async function updateFood(input: SavedFoodUpdateInput): Promise { + const profileId = await getProfileIdOrThrow(); + const payload = { + brand_name: input.brandName?.trim() || null, + description: input.description.trim(), + serving_size: input.servingSize.trim(), + servings_per_container: input.servingsPerContainer, + updated_at: new Date().toISOString(), + ...buildNutritionPayload(input), + }; + + const { data, error } = await supabase + .from(TABLE) + .update(payload) + .eq('id', input.id) + .eq('profile_id', profileId) + .select('*') + .single(); + + if (error) throw error; + + return mapRecord(data); +} + +export function savedFoodToFoodItem(savedFood: SavedFood): FoodItem { + return { + id: `saved_food_${savedFood.id}`, + name: savedFood.description, + brand: savedFood.brandName, + calories: savedFood.calories ?? 0, + protein: savedFood.protein ?? 0, + carbs: savedFood.totalCarbohydrates ?? 0, + fat: savedFood.totalFat ?? 0, + serving: savedFood.servingSize, + }; +} + diff --git a/supabase/migrations/20251129120000_create_foods_table.sql b/supabase/migrations/20251129120000_create_foods_table.sql new file mode 100644 index 0000000..de71a75 --- /dev/null +++ b/supabase/migrations/20251129120000_create_foods_table.sql @@ -0,0 +1,36 @@ +create table if not exists public.foods ( + id uuid default gen_random_uuid() primary key, + profile_id uuid default auth.uid() not null references public.profiles(id) on delete cascade, + brand_name text, + description text not null, + serving_size text not null, + servings_per_container numeric(6,2) not null default 1 constraint servings_per_container_positive check (servings_per_container > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists foods_profile_description_idx on public.foods (profile_id, description); + +alter table public.foods enable row level security; + +create policy "Insert own foods" on public.foods + for insert + to authenticated + with check ((select auth.uid()) = profile_id); + +create policy "Select own foods" on public.foods + for select + to authenticated + using ((select auth.uid()) = profile_id); + +create policy "Update own foods" on public.foods + for update + to authenticated + using ((select auth.uid()) = profile_id) + with check ((select auth.uid()) = profile_id); + +create policy "Delete own foods" on public.foods + for delete + to authenticated + using ((select auth.uid()) = profile_id); + diff --git a/supabase/migrations/20251129130000_extend_foods_nutrition.sql b/supabase/migrations/20251129130000_extend_foods_nutrition.sql new file mode 100644 index 0000000..4e4bad3 --- /dev/null +++ b/supabase/migrations/20251129130000_extend_foods_nutrition.sql @@ -0,0 +1,17 @@ +alter table public.foods + add column if not exists calories integer not null default 0, + add column if not exists total_fat numeric(6,2), + add column if not exists saturated_fat numeric(6,2), + add column if not exists polyunsaturated_fat numeric(6,2), + add column if not exists monounsaturated_fat numeric(6,2), + add column if not exists trans_fat numeric(6,2), + add column if not exists cholesterol integer, + add column if not exists sodium integer, + add column if not exists potassium integer, + add column if not exists total_carbohydrates numeric(6,2), + add column if not exists dietary_fiber numeric(6,2), + add column if not exists sugars numeric(6,2), + add column if not exists added_sugars numeric(6,2), + add column if not exists sugar_alcohols numeric(6,2), + add column if not exists protein numeric(6,2); + diff --git a/types/index.ts b/types/index.ts index 26a0eab..50546bd 100644 --- a/types/index.ts +++ b/types/index.ts @@ -109,6 +109,84 @@ export interface FoodSearchResult { metadata?: Record; } +export interface SavedFood { + id: string; + profileId: string; + brandName?: string; + description: string; + servingSize: string; + servingsPerContainer: number; + calories: number; + totalFat?: number; + saturatedFat?: number; + polyunsaturatedFat?: number; + monounsaturatedFat?: number; + transFat?: number; + cholesterol?: number; + sodium?: number; + potassium?: number; + totalCarbohydrates?: number; + dietaryFiber?: number; + sugars?: number; + addedSugars?: number; + sugarAlcohols?: number; + protein?: number; + createdAt: string; + updatedAt: string; +} + +export interface SavedFoodRecord { + id: string; + profile_id: string; + brand_name: string | null; + description: string; + serving_size: string; + servings_per_container: number | string; + calories: number | string; + total_fat: number | string | null; + saturated_fat: number | string | null; + polyunsaturated_fat: number | string | null; + monounsaturated_fat: number | string | null; + trans_fat: number | string | null; + cholesterol: number | string | null; + sodium: number | string | null; + potassium: number | string | null; + total_carbohydrates: number | string | null; + dietary_fiber: number | string | null; + sugars: number | string | null; + added_sugars: number | string | null; + sugar_alcohols: number | string | null; + protein: number | string | null; + created_at: string; + updated_at: string; +} + +export interface SavedFoodInput { + brandName?: string; + description: string; + servingSize: string; + servingsPerContainer: number; + calories: number; + totalFat?: number; + saturatedFat?: number; + polyunsaturatedFat?: number; + monounsaturatedFat?: number; + transFat?: number; + cholesterol?: number; + sodium?: number; + potassium?: number; + totalCarbohydrates?: number; + dietaryFiber?: number; + sugars?: number; + addedSugars?: number; + sugarAlcohols?: number; + protein?: number; +} + +export interface SavedFoodUpdateInput extends SavedFoodInput { + id: string; +} + export type FoodSearchAdapterId = 'open-food-facts' | 'ai-fast'; export interface FoodSearchRequest extends FoodSearchParams { @@ -373,3 +451,48 @@ export type AppStackScreenOptions = NativeStackNavigationOptions & { headerBackTitleVisible?: boolean; }; +export interface OutreachSearchDefinition { + id: string; + label: string; + query: string; + subreddit?: string; + sort?: 'relevance' | 'new' | 'top' | 'hot'; + timeframe?: 'hour' | 'day' | 'week' | 'month' | 'year' | 'all'; + keywords?: string[]; + angle?: string; + valueProp?: string; + commentTemplate?: string; + notes?: string; +} + +export interface OutreachTargetHit { + id: string; + queryId: string; + title: string; + permalink: string; + url: string; + author: string; + subreddit: string; + flair?: string | null; + createdUtc: number; + upvotes: number; + comments: number; + matchScore: number; + ageHours: number; + keywordsMatched: string[]; + summary?: string; + angle?: string; + valueProp?: string; + commentTemplate?: string; + status: 'new' | 'drafted' | 'posted' | 'skip'; +} + +export interface OutreachDigest { + generatedAt: string; + queries: Array<{ + definition: OutreachSearchDefinition; + hits: OutreachTargetHit[]; + error?: string; + }>; +} + From 8009e29267df811e5d53de97d08e96a466b9669c Mon Sep 17 00:00:00 2001 From: Jeff Loiselle Date: Sat, 29 Nov 2025 14:42:10 -0600 Subject: [PATCH 2/2] fix: fixes bugs fixes remaining bugs --- app/(tabs)/tools/recipes/index.tsx | 2 +- app/food-search.tsx | 18 ++++++++++-------- .../20251127215904_remote_schema.sql | 2 -- 3 files changed, 11 insertions(+), 11 deletions(-) diff --git a/app/(tabs)/tools/recipes/index.tsx b/app/(tabs)/tools/recipes/index.tsx index cfb6c05..0a38d9a 100644 --- a/app/(tabs)/tools/recipes/index.tsx +++ b/app/(tabs)/tools/recipes/index.tsx @@ -251,7 +251,7 @@ const createStyles = (theme: Theme) => }, errorText: { textAlign: 'center', - color: theme.error, + color: theme.danger, }, retryText: { color: theme.primary, diff --git a/app/food-search.tsx b/app/food-search.tsx index 3b5a6e1..2476813 100644 --- a/app/food-search.tsx +++ b/app/food-search.tsx @@ -3,14 +3,14 @@ import { Audio } from 'expo-av'; import { useRouter } from 'expo-router'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { - ActivityIndicator, - Alert, - FlatList, - Modal, - StyleSheet, - TextInput, - TouchableOpacity, - View, + ActivityIndicator, + Alert, + FlatList, + Modal, + StyleSheet, + TextInput, + TouchableOpacity, + View, } from 'react-native'; import { ThemedText } from '@/components/themed-text'; @@ -246,6 +246,7 @@ export default function FoodSearch() { useEffect(() => { if (!trimmedSearchQuery) { + savedSearchRequestId.current += 1; setResults([]); setSavedResults([]); setSavedResultsLoading(false); @@ -266,6 +267,7 @@ export default function FoodSearch() { async (query: string) => { const normalizedQuery = query.trim(); if (!normalizedQuery) { + savedSearchRequestId.current += 1; setSavedResults([]); setSavedResultsLoading(false); return; diff --git a/supabase/migrations/20251127215904_remote_schema.sql b/supabase/migrations/20251127215904_remote_schema.sql index eaa436c..3cb2718 100644 --- a/supabase/migrations/20251127215904_remote_schema.sql +++ b/supabase/migrations/20251127215904_remote_schema.sql @@ -1,5 +1,4 @@ -\restrict DcxICQMG3WtocX04KgJgHJjqCoplADZdRfMVaaO1T542gYKsohKw8DWHBaQVNxg SET statement_timeout = 0; @@ -516,6 +515,5 @@ ALTER DEFAULT PRIVILEGES FOR ROLE "postgres" IN SCHEMA "public" GRANT ALL ON TAB -\unrestrict DcxICQMG3WtocX04KgJgHJjqCoplADZdRfMVaaO1T542gYKsohKw8DWHBaQVNxg RESET ALL;