feat: add product selection step before meal planning

Inserts a new PlanProductsSheet as step 1 of the planning flow.
Users see their current products as a multi-select checklist (all
selected by default) before choosing the planning mode and dates.

- Empty state explains the benefit and offers "Add products" CTA
  while always allowing "Plan without products" to skip
- Selected product IDs flow through PlanMenuSheet →
  PlanDatePickerSheet → MenuService.generateForDates → backend
- Backend: added ProductIDs field to generate-menu request body;
  uses ListForPromptByIDs when set, ListForPrompt otherwise
- Backend: added Repository.ListForPromptByIDs (filtered SQL query)
- All 12 ARB locale files updated with planProducts* keys

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
dbastrikin
2026-03-23 16:07:28 +02:00
parent b6c75a3488
commit b38190ff5b
33 changed files with 1007 additions and 77 deletions

View File

@@ -31,6 +31,8 @@ type UserLoader interface {
// ProductLister returns human-readable product lines for the AI prompt.
type ProductLister interface {
ListForPrompt(ctx context.Context, userID string) ([]string, error)
// ListForPromptByIDs returns only the products with the given IDs.
ListForPromptByIDs(ctx context.Context, userID string, ids []string) ([]string, error)
}
// RecipeSaver creates a dish+recipe and returns the new recipe ID.
@@ -121,9 +123,10 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
}
var body struct {
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
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
ProductIDs []string `json:"product_ids"` // when set, only these products are passed to AI
}
_ = json.NewDecoder(r.Body).Decode(&body)
@@ -136,7 +139,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
}
if len(body.Dates) > 0 {
h.generateForDates(w, r, userID, u, body.Dates, body.MealTypes)
h.generateForDates(w, r, userID, u, body.Dates, body.MealTypes, body.ProductIDs)
return
}
@@ -150,8 +153,14 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
if len(body.ProductIDs) > 0 {
if products, productError := h.productLister.ListForPromptByIDs(r.Context(), userID, body.ProductIDs); productError == nil {
menuReq.AvailableProducts = products
}
} else {
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
}
}
days, generateError := h.menuGenerator.GenerateMenu(r.Context(), menuReq)
@@ -194,7 +203,7 @@ func (h *Handler) GenerateMenu(w http.ResponseWriter, r *http.Request) {
// 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) {
func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userID string, u *user.User, dates, requestedMealTypes, productIDs []string) {
mealTypes := requestedMealTypes
if len(mealTypes) == 0 {
// Fall back to user's preferred meal types.
@@ -212,8 +221,14 @@ func (h *Handler) generateForDates(w http.ResponseWriter, r *http.Request, userI
menuReq := buildMenuRequest(u, locale.FromContext(r.Context()))
menuReq.MealTypes = mealTypes
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
if len(productIDs) > 0 {
if products, productError := h.productLister.ListForPromptByIDs(r.Context(), userID, productIDs); productError == nil {
menuReq.AvailableProducts = products
}
} else {
if products, productError := h.productLister.ListForPrompt(r.Context(), userID); productError == nil {
menuReq.AvailableProducts = products
}
}
weekGroups := groupDatesByWeek(dates)