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:
@@ -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())
|
||||
|
||||
@@ -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, `
|
||||
|
||||
Reference in New Issue
Block a user