feat: flexible meal planning wizard — plan 1 meal, 1 day, several days, or a week

Backend:
- migration 005: expand menu_items.meal_type CHECK to all 6 types (second_breakfast, afternoon_snack, snack)
- ai/types.go: add Days and MealTypes to MenuRequest for partial generation
- openai/menu.go: parametrize GenerateMenu — use requested meal types and day count; add caloric fractions for all 6 meal types
- menu/repository.go: add UpsertItemsInTx for partial upsert (preserves existing slots); fix meal_type sort order in GetByWeek
- menu/handler.go: add dates+meal_types path to POST /ai/generate-menu; extract fetchImages/saveRecipes helpers; returns {"plans":[...]} for dates mode; backward-compatible with week mode

Client:
- PlanMenuSheet: bottom sheet with 4 planning horizon options
- PlanDatePickerSheet: adaptive sheet with date strip (single day/meal) or custom CalendarRangePicker (multi-day/week); sliding 7-day window for week mode
- menu_service.dart: add generateForDates
- menu_provider.dart: add PlanMenuService (generates + invalidates week providers), lastPlannedDateProvider
- home_screen.dart: add _PlanMenuButton card below quick actions; opens planning wizard
- l10n: 16 new keys for planning UI across all 12 languages

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-22 12:10:52 +02:00
parent 5096df2102
commit 9580bff54e
35 changed files with 2025 additions and 136 deletions

View File

@@ -108,6 +108,11 @@ func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) {
}
// GenerateMenu handles POST /ai/generate-menu
//
// Two modes:
// - dates mode (body.Dates non-empty): generate for specific dates and meal types,
// upsert only those slots; returns {"plans":[...]}.
// - week mode (existing): generate full 7-day week; returns a single MenuPlan.
func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
@@ -116,124 +121,232 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
}
var body struct {
Week string `json:"week"` // optional, defaults to current week
Week string `json:"week"` // optional, defaults to current week
Dates []string `json:"dates"` // YYYY-MM-DD; triggers partial generation
MealTypes []string `json:"meal_types"` // overrides user preference when set
}
_ = json.NewDecoder(r.Body).Decode(&body)
weekStart, err := ResolveWeekStart(body.Week)
if err != nil {
writeError(w, r, http.StatusBadRequest, "invalid week parameter")
// Load user profile (needed for both paths).
u, loadError := h.userLoader.GetByID(r.Context(), userID)
if loadError != nil {
slog.ErrorContext(r.Context(), "load user for menu generation", "err", loadError)
writeError(w, r, http.StatusInternalServerError, "failed to load user profile")
return
}
// Load user profile.
u, err := h.userLoader.GetByID(r.Context(), userID)
if err != nil {
slog.ErrorContext(r.Context(), "load user for menu generation", "err", err)
writeError(w, r, http.StatusInternalServerError, "failed to load user profile")
if len(body.Dates) > 0 {
h.generateForDates(w, r, userID, u, body.Dates, body.MealTypes)
return
}
// ── Full-week path (existing behaviour) ──────────────────────────────
weekStart, weekError := ResolveWeekStart(body.Week)
if weekError != nil {
writeError(w, r, http.StatusBadRequest, "invalid week parameter")
return
}
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
// Attach pantry products.
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
}
// Generate 7-day plan via Gemini.
days, err := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
if err != nil {
slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", err)
days, generateError := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
if generateError != nil {
slog.ErrorContext(r.Context(), "generate menu", "user_id", userID, "err", generateError)
writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again")
return
}
// Fetch Pexels images for all 21 recipes in parallel.
type indexedRecipe struct {
day int
meal int
imageURL string
}
imageResults := make([]indexedRecipe, 0, len(days)*3)
var mu sync.Mutex
var wg sync.WaitGroup
h.fetchImages(r.Context(), days)
for di, day := range days {
for mi := range day.Meals {
wg.Add(1)
go func(di, mi int, query string) {
defer wg.Done()
url, err := h.pexels.SearchPhoto(r.Context(), query)
if err != nil {
slog.WarnContext(r.Context(), "pexels search failed", "query", query, "err", err)
}
mu.Lock()
imageResults = append(imageResults, indexedRecipe{di, mi, url})
mu.Unlock()
}(di, mi, day.Meals[mi].Recipe.ImageQuery)
}
}
wg.Wait()
for _, res := range imageResults {
days[res.day].Meals[res.meal].Recipe.ImageURL = res.imageURL
planItems, saveError := h.saveRecipes(r.Context(), days)
if saveError != nil {
writeError(w, r, http.StatusInternalServerError, "failed to save recipes")
return
}
// Persist all 21 recipes as dish+recipe rows.
type savedRef struct {
day int
meal int
recipeID string
}
refs := make([]savedRef, 0, len(days)*3)
for di, day := range days {
for mi, meal := range day.Meals {
recipeID, err := h.recipeSaver.Create(r.Context(), recipeToCreateRequest(meal.Recipe))
if err != nil {
slog.ErrorContext(r.Context(), "save recipe for menu", "title", meal.Recipe.Title, "err", err)
writeError(w, r, http.StatusInternalServerError, "failed to save recipes")
return
}
refs = append(refs, savedRef{di, mi, recipeID})
}
}
// Build PlanItems list in day/meal order.
planItems := make([]PlanItem, 0, 21)
for _, ref := range refs {
planItems = append(planItems, PlanItem{
DayOfWeek: days[ref.day].Day,
MealType: days[ref.day].Meals[ref.meal].MealType,
RecipeID: ref.recipeID,
})
}
// Persist in a single transaction.
planID, err := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems)
if err != nil {
slog.ErrorContext(r.Context(), "save menu plan", "err", err)
planID, txError := h.repo.SaveMenuInTx(r.Context(), userID, weekStart, planItems)
if txError != nil {
slog.ErrorContext(r.Context(), "save menu plan", "err", txError)
writeError(w, r, http.StatusInternalServerError, "failed to save menu plan")
return
}
// Auto-generate shopping list.
if shoppingItems, err := h.buildShoppingList(r.Context(), planID); err == nil {
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); err != nil {
slog.WarnContext(r.Context(), "auto-generate shopping list", "err", err)
if shoppingItems, shoppingError := h.buildShoppingList(r.Context(), planID); shoppingError == nil {
if upsertError := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); upsertError != nil {
slog.WarnContext(r.Context(), "auto-generate shopping list", "err", upsertError)
}
}
// Return the freshly saved plan.
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
if err != nil || plan == nil {
slog.ErrorContext(r.Context(), "load generated menu", "err", err, "plan_nil", plan == nil)
plan, loadPlanError := h.repo.GetByWeek(r.Context(), userID, weekStart)
if loadPlanError != nil || plan == nil {
slog.ErrorContext(r.Context(), "load generated menu", "err", loadPlanError, "plan_nil", plan == nil)
writeError(w, r, http.StatusInternalServerError, "failed to load generated menu")
return
}
writeJSON(w, http.StatusOK, plan)
}
// generateForDates handles partial menu generation for specific dates and meal types.
// It groups dates by ISO week, generates only the requested slots, upserts them
// without touching other existing slots, and returns {"plans":[...]}.
func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userID string, u *user.User, dates, requestedMealTypes []string) {
mealTypes := requestedMealTypes
if len(mealTypes) == 0 {
// Fall back to user's preferred meal types.
var prefs struct {
MealTypes []string `json:"meal_types"`
}
if len(u.Preferences) > 0 {
_ = json.Unmarshal(u.Preferences, &prefs)
}
mealTypes = prefs.MealTypes
}
if len(mealTypes) == 0 {
mealTypes = []string{"breakfast", "lunch", "dinner"}
}
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
menuReq.MealTypes = mealTypes
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
}
weekGroups := groupDatesByWeek(dates)
var plans []*MenuPlan
for weekStart, datesInWeek := range weekGroups {
menuReq.Days = datesToDOW(datesInWeek)
days, generateError := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
if generateError != nil {
slog.ErrorContext(r.Context(), "generate menu for dates", "week", weekStart, "err", generateError)
writeError(w, r, http.StatusServiceUnavailable, "menu generation failed, please try again")
return
}
h.fetchImages(r.Context(), days)
planItems, saveError := h.saveRecipes(r.Context(), days)
if saveError != nil {
writeError(w, r, http.StatusInternalServerError, "failed to save recipes")
return
}
planID, upsertError := h.repo.UpsertItemsInTx(r.Context(), userID, weekStart, planItems)
if upsertError != nil {
slog.ErrorContext(r.Context(), "upsert menu items", "week", weekStart, "err", upsertError)
writeError(w, r, http.StatusInternalServerError, "failed to save menu plan")
return
}
if shoppingItems, shoppingError := h.buildShoppingList(r.Context(), planID); shoppingError == nil {
if upsertShoppingError := h.repo.UpsertShoppingList(r.Context(), userID, planID, shoppingItems); upsertShoppingError != nil {
slog.WarnContext(r.Context(), "auto-generate shopping list", "err", upsertShoppingError)
}
}
plan, loadError := h.repo.GetByWeek(r.Context(), userID, weekStart)
if loadError != nil || plan == nil {
slog.ErrorContext(r.Context(), "load generated plan", "week", weekStart, "err", loadError)
writeError(w, r, http.StatusInternalServerError, "failed to load generated menu")
return
}
plans = append(plans, plan)
}
writeJSON(w, http.StatusOK, map[string]any{"plans": plans})
}
// fetchImages fetches Pexels images for all meals in parallel, mutating days in place.
func (h *Handler) fetchImages(ctx context.Context, days []ai.DayPlan) {
type indexedResult struct {
day int
meal int
imageURL string
}
imageResults := make([]indexedResult, 0, len(days)*6)
var mu sync.Mutex
var wg sync.WaitGroup
for dayIndex, day := range days {
for mealIndex := range day.Meals {
wg.Add(1)
go func(di, mi int, query string) {
defer wg.Done()
url, fetchError := h.pexels.SearchPhoto(ctx, query)
if fetchError != nil {
slog.WarnContext(ctx, "pexels search failed", "query", query, "err", fetchError)
}
mu.Lock()
imageResults = append(imageResults, indexedResult{di, mi, url})
mu.Unlock()
}(dayIndex, mealIndex, day.Meals[mealIndex].Recipe.ImageQuery)
}
}
wg.Wait()
for _, result := range imageResults {
days[result.day].Meals[result.meal].Recipe.ImageURL = result.imageURL
}
}
// saveRecipes persists all recipes as dish+recipe rows and returns a PlanItem list.
func (h *Handler) saveRecipes(ctx context.Context, days []ai.DayPlan) ([]PlanItem, error) {
planItems := make([]PlanItem, 0, len(days)*6)
for _, day := range days {
for _, meal := range day.Meals {
recipeID, createError := h.recipeSaver.Create(ctx, recipeToCreateRequest(meal.Recipe))
if createError != nil {
slog.ErrorContext(ctx, "save recipe for menu", "title", meal.Recipe.Title, "err", createError)
return nil, createError
}
planItems = append(planItems, PlanItem{
DayOfWeek: day.Day,
MealType: meal.MealType,
RecipeID: recipeID,
})
}
}
return planItems, nil
}
// groupDatesByWeek groups YYYY-MM-DD date strings by their ISO week's Monday date.
func groupDatesByWeek(dates []string) map[string][]string {
result := map[string][]string{}
for _, date := range dates {
t, parseError := time.Parse("2006-01-02", date)
if parseError != nil {
continue
}
year, week := t.ISOWeek()
weekStart := mondayOfISOWeek(year, week).Format("2006-01-02")
result[weekStart] = append(result[weekStart], date)
}
return result
}
// datesToDOW converts date strings to ISO day-of-week values (1=Monday, 7=Sunday).
func datesToDOW(dates []string) []int {
dows := make([]int, 0, len(dates))
for _, date := range dates {
t, parseError := time.Parse("2006-01-02", date)
if parseError != nil {
continue
}
weekday := int(t.Weekday())
if weekday == 0 {
weekday = 7 // Go's Sunday=0 → ISO Sunday=7
}
dows = append(dows, weekday)
}
return dows
}
// UpdateMenuItem handles PUT /menu/items/{id}
func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())

View File

@@ -47,7 +47,15 @@ func (r *Repository) GetByWeek(ctx context.Context, userID, weekStart string) (*
LEFT JOIN dish_translations dt ON dt.dish_id = d.id AND dt.lang = $3
WHERE mp.user_id = $1 AND mp.week_start::text = $2
ORDER BY mi.day_of_week,
CASE mi.meal_type WHEN 'breakfast' THEN 1 WHEN 'lunch' THEN 2 ELSE 3 END`
CASE mi.meal_type
WHEN 'breakfast' THEN 1
WHEN 'second_breakfast' THEN 2
WHEN 'lunch' THEN 3
WHEN 'afternoon_snack' THEN 4
WHEN 'dinner' THEN 5
WHEN 'snack' THEN 6
ELSE 7
END`
rows, err := r.pool.Query(ctx, q, userID, weekStart, lang)
if err != nil {
@@ -163,6 +171,43 @@ func (r *Repository) SaveMenuInTx(ctx context.Context, userID, weekStart string,
return planID, nil
}
// UpsertItemsInTx creates (or retrieves) the menu_plan for the given week and
// upserts only the specified meal slots, leaving all other existing slots untouched.
func (r *Repository) UpsertItemsInTx(ctx context.Context, userID, weekStart string, items []PlanItem) (string, error) {
transaction, beginError := r.pool.BeginTx(ctx, pgx.TxOptions{})
if beginError != nil {
return "", fmt.Errorf("begin tx: %w", beginError)
}
defer transaction.Rollback(ctx) //nolint:errcheck
var planID string
upsertError := transaction.QueryRow(ctx, `
INSERT INTO menu_plans (user_id, week_start)
VALUES ($1, $2::date)
ON CONFLICT (user_id, week_start) DO UPDATE SET created_at = now()
RETURNING id`, userID, weekStart).Scan(&planID)
if upsertError != nil {
return "", fmt.Errorf("upsert menu_plan: %w", upsertError)
}
for _, item := range items {
if _, insertError := transaction.Exec(ctx, `
INSERT INTO menu_items (menu_plan_id, day_of_week, meal_type, recipe_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (menu_plan_id, day_of_week, meal_type)
DO UPDATE SET recipe_id = EXCLUDED.recipe_id`,
planID, item.DayOfWeek, item.MealType, item.RecipeID,
); insertError != nil {
return "", fmt.Errorf("upsert menu item day=%d meal=%s: %w", item.DayOfWeek, item.MealType, insertError)
}
}
if commitError := transaction.Commit(ctx); commitError != nil {
return "", fmt.Errorf("commit tx: %w", commitError)
}
return planID, nil
}
// UpdateItem replaces the recipe in a menu slot.
func (r *Repository) UpdateItem(ctx context.Context, itemID, userID, recipeID string) error {
tag, err := r.pool.Exec(ctx, `