feat: implement Iteration 4 — menu planning, shopping list, diary

Backend:
- Migrations 007 (menu_plans, menu_items, shopping_lists) and
  008 (meal_diary)
- gemini/menu.go: GenerateMenu — 7-day × 3-meal plan via one Groq call
- internal/menu: model, repository (GetByWeek, SaveMenuInTx,
  shopping list CRUD), handler (GET/PUT/DELETE /menu,
  POST /ai/generate-menu, shopping list endpoints)
- internal/diary: model, repository, handler (GET/POST/DELETE /diary)
- Increase server WriteTimeout to 120s for long AI calls
- api_client.go: add patch() and postList() helpers

Flutter:
- shared/models: menu.dart, shopping_item.dart, diary_entry.dart
- features/menu: menu_service.dart, menu_provider.dart
  (MenuNotifier, ShoppingListNotifier, DiaryNotifier with family)
- MenuScreen: 7-day view, week nav, skeleton on generation,
  generate FAB with confirmation dialog
- ShoppingListScreen: items by category, optimistic checkbox toggle
- DiaryScreen: daily entries with swipe-to-delete, add-entry sheet
- Router: /menu/shopping-list and /menu/diary routes

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-02-22 12:00:25 +02:00
parent 612a0eda60
commit ea8e207a45
22 changed files with 2926 additions and 12 deletions

View File

@@ -0,0 +1,545 @@
package menu
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"net/http"
"strings"
"sync"
"time"
"github.com/food-ai/backend/internal/gemini"
"github.com/food-ai/backend/internal/middleware"
"github.com/food-ai/backend/internal/savedrecipe"
"github.com/food-ai/backend/internal/user"
"github.com/go-chi/chi/v5"
)
// PhotoSearcher searches for a photo by query string.
type PhotoSearcher interface {
SearchPhoto(ctx context.Context, query string) (string, error)
}
// UserLoader loads a user profile by ID.
type UserLoader interface {
GetByID(ctx context.Context, id string) (*user.User, error)
}
// ProductLister returns human-readable product lines for the AI prompt.
type ProductLister interface {
ListForPrompt(ctx context.Context, userID string) ([]string, error)
}
// RecipeSaver persists a single recipe and returns the stored record.
type RecipeSaver interface {
Save(ctx context.Context, userID string, req savedrecipe.SaveRequest) (*savedrecipe.SavedRecipe, error)
}
// Handler handles menu and shopping-list endpoints.
type Handler struct {
repo *Repository
gemini *gemini.Client
pexels PhotoSearcher
userLoader UserLoader
productLister ProductLister
recipeSaver RecipeSaver
}
// NewHandler creates a new Handler.
func NewHandler(
repo *Repository,
geminiClient *gemini.Client,
pexels PhotoSearcher,
userLoader UserLoader,
productLister ProductLister,
recipeSaver RecipeSaver,
) *Handler {
return &Handler{
repo: repo,
gemini: geminiClient,
pexels: pexels,
userLoader: userLoader,
productLister: productLister,
recipeSaver: recipeSaver,
}
}
// ────────────────────────────────────────────────────────────
// Menu endpoints
// ────────────────────────────────────────────────────────────
// GetMenu handles GET /menu?week=YYYY-WNN
func (h *Handler) GetMenu(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter, expected YYYY-WNN")
return
}
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
if err != nil {
slog.Error("get menu", "err", err)
writeError(w, http.StatusInternalServerError, "failed to load menu")
return
}
if plan == nil {
// No plan yet — return empty response.
writeJSON(w, http.StatusOK, map[string]any{
"week_start": weekStart,
"days": nil,
})
return
}
writeJSON(w, http.StatusOK, plan)
}
// GenerateMenu handles POST /ai/generate-menu
func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var body struct {
Week string `json:"week"` // optional, defaults to current week
}
_ = json.NewDecoder(r.Body).Decode(&body)
weekStart, err := resolveWeekStart(body.Week)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter")
return
}
// Load user profile.
u, err := h.userLoader.GetByID(r.Context(), userID)
if err != nil {
slog.Error("load user for menu generation", "err", err)
writeError(w, http.StatusInternalServerError, "failed to load user profile")
return
}
menuReq := buildMenuRequest(u)
// Attach pantry products.
if products, err := h.productLister.ListForPrompt(r.Context(), userID); err == nil {
menuReq.AvailableProducts = products
}
// Generate 7-day plan via Groq.
days, err := h.gemini.GenerateMenu(r.Context(), menuReq)
if err != nil {
slog.Error("generate menu", "user_id", userID, "err", err)
writeError(w, 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
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.Warn("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
}
// Save all 21 recipes to saved_recipes.
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 {
saved, err := h.recipeSaver.Save(r.Context(), userID, recipeToSaveRequest(meal.Recipe))
if err != nil {
slog.Error("save recipe for menu", "title", meal.Recipe.Title, "err", err)
writeError(w, http.StatusInternalServerError, "failed to save recipes")
return
}
refs = append(refs, savedRef{di, mi, saved.ID})
}
}
// 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.Error("save menu plan", "err", err)
writeError(w, 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.Warn("auto-generate shopping list", "err", err)
}
}
// Return the freshly saved plan.
plan, err := h.repo.GetByWeek(r.Context(), userID, weekStart)
if err != nil || plan == nil {
writeError(w, http.StatusInternalServerError, "failed to load generated menu")
return
}
writeJSON(w, http.StatusOK, plan)
}
// UpdateMenuItem handles PUT /menu/items/{id}
func (h *Handler) UpdateMenuItem(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
itemID := chi.URLParam(r, "id")
var body struct {
RecipeID string `json:"recipe_id"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.RecipeID == "" {
writeError(w, http.StatusBadRequest, "recipe_id required")
return
}
if err := h.repo.UpdateItem(r.Context(), itemID, userID, body.RecipeID); err != nil {
if err == ErrNotFound {
writeError(w, http.StatusNotFound, "menu item not found")
return
}
slog.Error("update menu item", "err", err)
writeError(w, http.StatusInternalServerError, "failed to update menu item")
return
}
w.WriteHeader(http.StatusNoContent)
}
// DeleteMenuItem handles DELETE /menu/items/{id}
func (h *Handler) DeleteMenuItem(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
itemID := chi.URLParam(r, "id")
if err := h.repo.DeleteItem(r.Context(), itemID, userID); err != nil {
if err == ErrNotFound {
writeError(w, http.StatusNotFound, "menu item not found")
return
}
slog.Error("delete menu item", "err", err)
writeError(w, http.StatusInternalServerError, "failed to delete menu item")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ────────────────────────────────────────────────────────────
// Shopping list endpoints
// ────────────────────────────────────────────────────────────
// GenerateShoppingList handles POST /shopping-list/generate
func (h *Handler) GenerateShoppingList(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
var body struct {
Week string `json:"week"`
}
_ = json.NewDecoder(r.Body).Decode(&body)
weekStart, err := resolveWeekStart(body.Week)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter")
return
}
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
if err != nil {
if err == ErrNotFound {
writeError(w, http.StatusNotFound, "no menu plan found for this week")
return
}
slog.Error("get plan id", "err", err)
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
return
}
items, err := h.buildShoppingList(r.Context(), planID)
if err != nil {
slog.Error("build shopping list", "err", err)
writeError(w, http.StatusInternalServerError, "failed to build shopping list")
return
}
if err := h.repo.UpsertShoppingList(r.Context(), userID, planID, items); err != nil {
slog.Error("upsert shopping list", "err", err)
writeError(w, http.StatusInternalServerError, "failed to save shopping list")
return
}
writeJSON(w, http.StatusOK, items)
}
// GetShoppingList handles GET /shopping-list?week=YYYY-WNN
func (h *Handler) GetShoppingList(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter")
return
}
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
if err != nil {
if err == ErrNotFound {
writeJSON(w, http.StatusOK, []ShoppingItem{})
return
}
writeError(w, http.StatusInternalServerError, "failed to find menu plan")
return
}
items, err := h.repo.GetShoppingList(r.Context(), userID, planID)
if err != nil {
writeError(w, http.StatusInternalServerError, "failed to load shopping list")
return
}
if items == nil {
items = []ShoppingItem{}
}
writeJSON(w, http.StatusOK, items)
}
// ToggleShoppingItem handles PATCH /shopping-list/items/{index}/check
func (h *Handler) ToggleShoppingItem(w http.ResponseWriter, r *http.Request) {
userID := middleware.UserIDFromCtx(r.Context())
if userID == "" {
writeError(w, http.StatusUnauthorized, "unauthorized")
return
}
indexStr := chi.URLParam(r, "index")
var index int
if _, err := fmt.Sscanf(indexStr, "%d", &index); err != nil || index < 0 {
writeError(w, http.StatusBadRequest, "invalid item index")
return
}
var body struct {
Checked bool `json:"checked"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid request body")
return
}
weekStart, err := resolveWeekStart(r.URL.Query().Get("week"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid week parameter")
return
}
planID, err := h.repo.GetPlanIDByWeek(r.Context(), userID, weekStart)
if err != nil {
writeError(w, http.StatusNotFound, "menu plan not found")
return
}
if err := h.repo.ToggleShoppingItem(r.Context(), userID, planID, index, body.Checked); err != nil {
slog.Error("toggle shopping item", "err", err)
writeError(w, http.StatusInternalServerError, "failed to update item")
return
}
w.WriteHeader(http.StatusNoContent)
}
// ────────────────────────────────────────────────────────────
// Helpers
// ────────────────────────────────────────────────────────────
// buildShoppingList aggregates all ingredients from a plan's recipes.
func (h *Handler) buildShoppingList(ctx context.Context, planID string) ([]ShoppingItem, error) {
rows, err := h.repo.GetIngredientsByPlan(ctx, planID)
if err != nil {
return nil, err
}
type key struct{ name, unit string }
totals := map[key]float64{}
categories := map[string]string{} // name → category (from meal_type heuristic)
for _, row := range rows {
var ingredients []struct {
Name string `json:"name"`
Amount float64 `json:"amount"`
Unit string `json:"unit"`
}
if len(row.IngredientsJSON) > 0 {
if err := json.Unmarshal(row.IngredientsJSON, &ingredients); err != nil {
continue
}
}
for _, ing := range ingredients {
k := key{strings.ToLower(strings.TrimSpace(ing.Name)), ing.Unit}
totals[k] += ing.Amount
if _, ok := categories[k.name]; !ok {
categories[k.name] = "other"
}
}
}
items := make([]ShoppingItem, 0, len(totals))
for k, amount := range totals {
items = append(items, ShoppingItem{
Name: k.name,
Category: categories[k.name],
Amount: amount,
Unit: k.unit,
Checked: false,
InStock: 0,
})
}
return items, nil
}
type userPreferences struct {
Cuisines []string `json:"cuisines"`
Restrictions []string `json:"restrictions"`
}
func buildMenuRequest(u *user.User) gemini.MenuRequest {
req := gemini.MenuRequest{DailyCalories: 2000}
if u.Goal != nil {
req.UserGoal = *u.Goal
}
if u.DailyCalories != nil && *u.DailyCalories > 0 {
req.DailyCalories = *u.DailyCalories
}
if len(u.Preferences) > 0 {
var prefs userPreferences
if err := json.Unmarshal(u.Preferences, &prefs); err == nil {
req.CuisinePrefs = prefs.Cuisines
req.Restrictions = prefs.Restrictions
}
}
return req
}
func recipeToSaveRequest(r gemini.Recipe) savedrecipe.SaveRequest {
ingJSON, _ := json.Marshal(r.Ingredients)
stepsJSON, _ := json.Marshal(r.Steps)
tagsJSON, _ := json.Marshal(r.Tags)
nutritionJSON, _ := json.Marshal(r.Nutrition)
return savedrecipe.SaveRequest{
Title: r.Title,
Description: r.Description,
Cuisine: r.Cuisine,
Difficulty: r.Difficulty,
PrepTimeMin: r.PrepTimeMin,
CookTimeMin: r.CookTimeMin,
Servings: r.Servings,
ImageURL: r.ImageURL,
Ingredients: ingJSON,
Steps: stepsJSON,
Tags: tagsJSON,
Nutrition: nutritionJSON,
Source: "menu",
}
}
// resolveWeekStart parses "YYYY-WNN" or returns current week's Monday.
func resolveWeekStart(week string) (string, error) {
if week == "" {
return currentWeekStart(), nil
}
var year, w int
if _, err := fmt.Sscanf(week, "%d-W%d", &year, &w); err != nil || w < 1 || w > 53 {
return "", fmt.Errorf("invalid week: %q", week)
}
t := mondayOfISOWeek(year, w)
return t.Format("2006-01-02"), nil
}
func currentWeekStart() string {
now := time.Now().UTC()
year, week := now.ISOWeek()
return mondayOfISOWeek(year, week).Format("2006-01-02")
}
func mondayOfISOWeek(year, week int) time.Time {
jan4 := time.Date(year, time.January, 4, 0, 0, 0, 0, time.UTC)
weekday := int(jan4.Weekday())
if weekday == 0 {
weekday = 7
}
monday1 := jan4.AddDate(0, 0, 1-weekday)
return monday1.AddDate(0, 0, (week-1)*7)
}
type errorResponse struct {
Error string `json:"error"`
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(errorResponse{Error: msg})
}
func writeJSON(w http.ResponseWriter, status int, v any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(v)
}