349 lines
16 KiB
TypeScript
349 lines
16 KiB
TypeScript
import { useMemo, useState } from 'react';
|
||
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||
import { router } from 'expo-router';
|
||
import { useTranslation } from 'react-i18next';
|
||
import { deleteMealPlanEntry, fetchMealPlanEntries, upsertMealPlanEntry } from '@/src/api/meal-plan.api';
|
||
import { fetchGoals } from '@/src/api/diet.api';
|
||
import { fetchRecipes } from '@/src/api/recipes.api';
|
||
import { Button } from '@/src/components/Button';
|
||
import { ErrorView } from '@/src/components/ErrorView';
|
||
import { Input } from '@/src/components/Input';
|
||
import { Screen } from '@/src/components/Screen';
|
||
import { useAppearance } from '@/src/context/AppearanceContext';
|
||
import { CONSUMPTION_CATEGORIES } from '@/src/types/diet';
|
||
import type { MealPlanEntry } from '@/src/types/meal-plan';
|
||
import { MealCategory } from '@/src/types/recipe';
|
||
import { addDays, formatDisplayDate, weekDays, weekRange } from '@/src/utils/date';
|
||
import { categoryLabel } from '@/src/utils/recipe';
|
||
import {
|
||
computeDayMealPlanning,
|
||
estimateEntryCalories,
|
||
rankRecipeSuggestions,
|
||
} from '@/src/utils/mealPlannerSuggestions';
|
||
|
||
const PLANNER_CATEGORIES = CONSUMPTION_CATEGORIES.filter((c) => c.value <= MealCategory.Dinner);
|
||
|
||
export default function PlannerScreen() {
|
||
const { t, i18n } = useTranslation();
|
||
const { colors } = useAppearance();
|
||
const queryClient = useQueryClient();
|
||
const [weekAnchor, setWeekAnchor] = useState(() => weekRange().from);
|
||
const { from, to } = useMemo(() => weekRange(weekAnchor), [weekAnchor]);
|
||
const days = useMemo(() => weekDays(from), [from]);
|
||
|
||
const [picker, setPicker] = useState<{ date: string; mealCategory: number; entry?: MealPlanEntry } | null>(null);
|
||
const [recipeSearch, setRecipeSearch] = useState('');
|
||
const [portions, setPortions] = useState('1');
|
||
const [dailyTargets, setDailyTargets] = useState<Record<string, number>>({});
|
||
const [selectedRecipeId, setSelectedRecipeId] = useState<number | null>(null);
|
||
|
||
const entriesQuery = useQuery({
|
||
queryKey: ['meal-plan', from, to],
|
||
queryFn: () => fetchMealPlanEntries(from, to),
|
||
});
|
||
|
||
const goalsQuery = useQuery({
|
||
queryKey: ['diet-goals'],
|
||
queryFn: fetchGoals,
|
||
});
|
||
|
||
const recipesQuery = useQuery({
|
||
queryKey: ['recipes', 'planner-search', recipeSearch],
|
||
queryFn: () => fetchRecipes({ search: recipeSearch }),
|
||
enabled: !!picker && recipeSearch.trim().length >= 2,
|
||
});
|
||
|
||
const suggestionsQuery = useQuery({
|
||
queryKey: ['recipes', 'planner-suggestions', picker?.mealCategory],
|
||
queryFn: () => fetchRecipes({ category: picker!.mealCategory as MealCategory }),
|
||
enabled: !!picker,
|
||
});
|
||
|
||
const entries = entriesQuery.data ?? [];
|
||
|
||
const dailyTarget = picker
|
||
? (dailyTargets[picker.date] ?? goalsQuery.data?.calorieGoal ?? 0)
|
||
: 0;
|
||
|
||
const selectedRecipe = useMemo(() => {
|
||
if (!selectedRecipeId) return null;
|
||
return (
|
||
recipesQuery.data?.find((r) => r.id === selectedRecipeId) ??
|
||
suggestionsQuery.data?.find((r) => r.id === selectedRecipeId) ??
|
||
null
|
||
);
|
||
}, [selectedRecipeId, recipesQuery.data, suggestionsQuery.data]);
|
||
|
||
const estimatedMealCalories = estimateEntryCalories(selectedRecipe, Number(portions) || 1);
|
||
|
||
const dayPlanning = useMemo(() => {
|
||
if (!picker) return null;
|
||
const pending =
|
||
estimatedMealCalories != null && estimatedMealCalories > 0
|
||
? { category: picker.mealCategory, calories: estimatedMealCalories }
|
||
: null;
|
||
return computeDayMealPlanning(picker.date, picker.mealCategory, entries, dailyTarget, pending);
|
||
}, [picker, entries, dailyTarget, estimatedMealCalories]);
|
||
|
||
const suggestions = useMemo(() => {
|
||
if (!picker || !dayPlanning || !suggestionsQuery.data) return [];
|
||
return rankRecipeSuggestions(suggestionsQuery.data, picker.mealCategory, dayPlanning.slotTarget);
|
||
}, [picker, dayPlanning, suggestionsQuery.data]);
|
||
|
||
const upsertMutation = useMutation({
|
||
mutationFn: upsertMealPlanEntry,
|
||
onSuccess: () => {
|
||
setPicker(null);
|
||
setRecipeSearch('');
|
||
setPortions('1');
|
||
setSelectedRecipeId(null);
|
||
void queryClient.invalidateQueries({ queryKey: ['meal-plan'] });
|
||
},
|
||
});
|
||
|
||
const deleteMutation = useMutation({
|
||
mutationFn: deleteMealPlanEntry,
|
||
onSuccess: () => void queryClient.invalidateQueries({ queryKey: ['meal-plan'] }),
|
||
});
|
||
|
||
function selectFromSearch(recipeId: number) {
|
||
setSelectedRecipeId(recipeId);
|
||
setRecipeSearch('');
|
||
}
|
||
|
||
function selectFromSuggestion(recipeId: number) {
|
||
setSelectedRecipeId(recipeId);
|
||
}
|
||
|
||
function clearSelection() {
|
||
setSelectedRecipeId(null);
|
||
}
|
||
|
||
function entryFor(date: string, mealCategory: number): MealPlanEntry | undefined {
|
||
return entriesQuery.data?.find((e) => e.planDate === date && e.mealCategory === mealCategory);
|
||
}
|
||
|
||
return (
|
||
<Screen title={t('planner.title')} subtitle={t('planner.subtitle')}>
|
||
<View style={styles.header}>
|
||
<Pressable
|
||
onPress={() => setWeekAnchor(addDays(from, -7))}
|
||
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
|
||
>
|
||
<Text style={{ color: colors.text }}>‹</Text>
|
||
</Pressable>
|
||
<Text style={{ color: colors.text, fontWeight: '700', flex: 1, textAlign: 'center' }}>
|
||
{formatDisplayDate(from, i18n.language)} – {formatDisplayDate(to, i18n.language)}
|
||
</Text>
|
||
<Pressable
|
||
onPress={() => setWeekAnchor(addDays(from, 7))}
|
||
style={[styles.navBtn, { borderColor: colors.border, backgroundColor: colors.surface }]}
|
||
>
|
||
<Text style={{ color: colors.text }}>›</Text>
|
||
</Pressable>
|
||
</View>
|
||
|
||
<Button title={t('planner.openShopping')} variant="secondary" onPress={() => router.push('/(main)/(tabs)/shopping')} />
|
||
|
||
{entriesQuery.isLoading ? (
|
||
<Text style={{ color: colors.textMuted }}>{t('common.loading')}</Text>
|
||
) : entriesQuery.isError ? (
|
||
<ErrorView message={t('errors.loadPlannerFailed')} onRetry={() => entriesQuery.refetch()} />
|
||
) : (
|
||
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
|
||
<View>
|
||
<View style={styles.row}>
|
||
<View style={styles.corner} />
|
||
{days.map((day) => (
|
||
<View key={day} style={[styles.dayHead, { borderColor: colors.border }]}>
|
||
<Text style={{ color: colors.textMuted, fontSize: 11, fontWeight: '600' }}>
|
||
{formatDisplayDate(day, i18n.language)}
|
||
</Text>
|
||
</View>
|
||
))}
|
||
</View>
|
||
{PLANNER_CATEGORIES.map((cat) => (
|
||
<View key={cat.value} style={styles.row}>
|
||
<View style={[styles.catLabel, { borderColor: colors.border, backgroundColor: colors.surface }]}>
|
||
<Text style={{ color: colors.text, fontSize: 11, fontWeight: '600' }}>{t(cat.labelKey)}</Text>
|
||
</View>
|
||
{days.map((day) => {
|
||
const entry = entryFor(day, cat.value);
|
||
return (
|
||
<Pressable
|
||
key={`${day}-${cat.value}`}
|
||
onPress={() => {
|
||
setPicker({ date: day, mealCategory: cat.value, entry });
|
||
setRecipeSearch('');
|
||
setPortions('1');
|
||
setSelectedRecipeId(null);
|
||
}}
|
||
onLongPress={() => {
|
||
if (!entry) return;
|
||
Alert.alert(t('planner.removeEntry'), entry.recipeName, [
|
||
{ text: t('common.cancel'), style: 'cancel' },
|
||
{ text: t('common.delete'), style: 'destructive', onPress: () => deleteMutation.mutate(entry.id) },
|
||
]);
|
||
}}
|
||
style={[styles.cell, { borderColor: colors.border, backgroundColor: colors.surface }]}
|
||
>
|
||
<Text style={{ color: entry ? colors.text : colors.textMuted, fontSize: 11 }} numberOfLines={2}>
|
||
{entry?.recipeName ?? '+'}
|
||
</Text>
|
||
</Pressable>
|
||
);
|
||
})}
|
||
</View>
|
||
))}
|
||
</View>
|
||
</ScrollView>
|
||
)}
|
||
|
||
<Modal visible={!!picker} transparent animationType="slide" onRequestClose={() => setPicker(null)}>
|
||
<View style={styles.modalBackdrop}>
|
||
<View style={[styles.modal, { backgroundColor: colors.background }]}>
|
||
<Text style={[styles.modalTitle, { color: colors.text }]}>{t('planner.assignRecipe')}</Text>
|
||
{picker ? (
|
||
<Text style={{ color: colors.textMuted, marginBottom: 8 }}>
|
||
{formatDisplayDate(picker.date, i18n.language)} · {categoryLabel(picker.mealCategory as MealCategory, t)}
|
||
</Text>
|
||
) : null}
|
||
<Input
|
||
label={t('planner.dailyTarget')}
|
||
keyboardType="number-pad"
|
||
value={String(dailyTarget || '')}
|
||
onChangeText={(text) => {
|
||
if (!picker) return;
|
||
const value = Number(text);
|
||
setDailyTargets((prev) => ({
|
||
...prev,
|
||
[picker.date]: Number.isFinite(value) ? value : 0,
|
||
}));
|
||
}}
|
||
/>
|
||
{dayPlanning && dailyTarget > 0 ? (
|
||
<View style={[styles.summaryBox, { backgroundColor: colors.surface, borderColor: colors.border }]}>
|
||
<Text style={{ color: colors.text, fontSize: 13 }}>
|
||
{t('planner.daySummary', { logged: dayPlanning.logged, target: dayPlanning.dailyTarget })}
|
||
</Text>
|
||
<Text style={{ color: colors.textMuted, fontSize: 11, marginTop: 4 }}>
|
||
{t('planner.remaining', { count: Math.max(0, dayPlanning.remaining) })}
|
||
{dayPlanning.slotTarget > 0
|
||
? ` · ${t('planner.slotHint', { count: dayPlanning.slotTarget })}`
|
||
: ''}
|
||
{dayPlanning.pendingCalories > 0
|
||
? ` · ${t('planner.includingSelection', { count: dayPlanning.pendingCalories })}`
|
||
: ''}
|
||
</Text>
|
||
</View>
|
||
) : (
|
||
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{t('planner.noSuggestions')}</Text>
|
||
)}
|
||
{suggestions.length > 0 ? (
|
||
<View>
|
||
<Text style={{ color: colors.text, fontWeight: '600', marginBottom: 6 }}>
|
||
{t('planner.suggestionsTitle')}
|
||
</Text>
|
||
<ScrollView style={{ maxHeight: 160 }}>
|
||
{suggestions.map(({ recipe, diff }) => (
|
||
<Pressable
|
||
key={recipe.id}
|
||
onPress={() => selectFromSuggestion(recipe.id)}
|
||
style={[
|
||
styles.recipePick,
|
||
{
|
||
borderColor: selectedRecipeId === recipe.id ? colors.brand : colors.border,
|
||
borderWidth: selectedRecipeId === recipe.id ? 2 : 1,
|
||
backgroundColor: colors.surface,
|
||
},
|
||
]}
|
||
>
|
||
<Text style={{ color: colors.text }}>{recipe.name}</Text>
|
||
<Text style={{ color: colors.textMuted, fontSize: 11 }}>
|
||
{recipe.calories} kcal{diff > 0 ? ` · ±${diff}` : ''}
|
||
</Text>
|
||
</Pressable>
|
||
))}
|
||
</ScrollView>
|
||
</View>
|
||
) : null}
|
||
<Input placeholder={t('recipes.searchRecipes')} value={recipeSearch} onChangeText={setRecipeSearch} />
|
||
{recipeSearch.trim().length >= 2 && !selectedRecipeId ? (
|
||
<ScrollView style={{ maxHeight: 200 }}>
|
||
{recipesQuery.data?.map((r) => (
|
||
<Pressable
|
||
key={r.id}
|
||
onPress={() => selectFromSearch(r.id)}
|
||
style={[styles.recipePick, { borderColor: colors.border, backgroundColor: colors.surface }]}
|
||
>
|
||
<Text style={{ color: colors.text }}>{r.name}</Text>
|
||
{r.calories != null ? (
|
||
<Text style={{ color: colors.textMuted, fontSize: 11 }}>{r.calories} kcal</Text>
|
||
) : null}
|
||
</Pressable>
|
||
))}
|
||
{recipesQuery.isFetched && !recipesQuery.isLoading && recipesQuery.data?.length === 0 ? (
|
||
<Text style={{ color: colors.textMuted, fontSize: 12 }}>{t('recipes.noMatchingTitle')}</Text>
|
||
) : null}
|
||
</ScrollView>
|
||
) : null}
|
||
{selectedRecipe ? (
|
||
<View style={[styles.summaryBox, { backgroundColor: colors.surface, borderColor: colors.brand }]}>
|
||
<View style={{ flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
|
||
<Text style={{ color: colors.text, fontSize: 13, flex: 1 }}>
|
||
{t('planner.selectedRecipe', { name: selectedRecipe.name })}
|
||
{estimatedMealCalories != null
|
||
? ` · ${estimatedMealCalories} kcal`
|
||
: selectedRecipe.calories != null
|
||
? ` · ${selectedRecipe.calories} kcal`
|
||
: ''}
|
||
</Text>
|
||
<Pressable onPress={clearSelection} hitSlop={8}>
|
||
<Text style={{ color: colors.textMuted }}>✕</Text>
|
||
</Pressable>
|
||
</View>
|
||
</View>
|
||
) : null}
|
||
<Input label={t('diet.portions')} keyboardType="decimal-pad" value={portions} onChangeText={setPortions} />
|
||
{estimatedMealCalories != null ? (
|
||
<Text style={{ color: colors.textMuted, fontSize: 12 }}>
|
||
{t('planner.estimatedMeal', { count: estimatedMealCalories })}
|
||
</Text>
|
||
) : null}
|
||
<Button
|
||
title={t('common.save')}
|
||
disabled={!selectedRecipeId || upsertMutation.isPending}
|
||
onPress={() => {
|
||
if (!picker || !selectedRecipeId) return;
|
||
upsertMutation.mutate({
|
||
planDate: picker.date,
|
||
mealCategory: picker.mealCategory,
|
||
recipeId: selectedRecipeId,
|
||
portions: Number(portions) || 1,
|
||
});
|
||
}}
|
||
/>
|
||
<Button title={t('common.cancel')} variant="ghost" onPress={() => setPicker(null)} />
|
||
</View>
|
||
</View>
|
||
</Modal>
|
||
</Screen>
|
||
);
|
||
}
|
||
|
||
const styles = StyleSheet.create({
|
||
header: { flexDirection: 'row', alignItems: 'center', gap: 8, marginBottom: 8 },
|
||
navBtn: { borderWidth: 1, borderRadius: 8, width: 36, height: 36, alignItems: 'center', justifyContent: 'center' },
|
||
row: { flexDirection: 'row' },
|
||
corner: { width: 72 },
|
||
dayHead: { width: 88, padding: 6, borderWidth: 1, alignItems: 'center' },
|
||
catLabel: { width: 72, padding: 6, borderWidth: 1, justifyContent: 'center' },
|
||
cell: { width: 88, height: 56, padding: 6, borderWidth: 1, justifyContent: 'center' },
|
||
modalBackdrop: { flex: 1, backgroundColor: 'rgba(0,0,0,0.45)', justifyContent: 'flex-end' },
|
||
modal: { borderTopLeftRadius: 16, borderTopRightRadius: 16, padding: 16, gap: 10, maxHeight: '80%' },
|
||
modalTitle: { fontSize: 18, fontWeight: '700' },
|
||
summaryBox: { borderWidth: 1, borderRadius: 10, padding: 10 },
|
||
recipePick: { borderWidth: 1, borderRadius: 8, padding: 10, marginBottom: 6 },
|
||
});
|